在算法性能上咱们经常面临的挑战是咱们的程序可否求解实际中的大型输入:
--为何程序运行的慢?
--为何程序耗尽了内存?程序员
没有理解算法的性能特征会致使客户端的性能不好,为了不这种状况的出线,须要具有算法分析的一些知识。
此篇主要涉及一些基础数学知识和科学方法,以及如何在实践应用中使用这些方法理解算法的性能。咱们的重点放在得到性能的预测上。
主要分为5部分:面试
注:下文我所的增加量级和增加阶数是一个东西其实...算法
咱们将从多种不一样的角色思考这些问题:spring
关于算法分析须要集中考虑的关键是运行时间。运行时间也能够理解为完成一项计算咱们须要进行多少次操做。
这里主要关心:apache
算法分析的科学方法概述:segmentfault
使用科学方法有一些基本原则:数组
(可证伪性:指从一个理论推导出来的结论(解释、预见)在逻辑上或原则上要有与一个或一组观察陈述与之发生冲突或抵触的可能。
可证伪,不等于已经被证伪;可证伪,不等因而错的。)缓存
第一步是要观察算法的性能特色,这里就是要观察程序的运行时间。
给程序计时的方法:网络
咱们将使用 3-SUM 问题做为观察的例子。
三数之和。若是有N个不一样的整数,以3个整数划为一组,有多少组整数只和为0.
以下图,8ints.txt 有8个整数,有四组整数和为0
目标是编写一个程序,能对任意输入计算出3-SUM整数和为0有多少组。
这个程序实现的算法也很简单,首先是第一种,“暴力算法”
EN:brute-force algorithm
这里使用第三方API的方法测量程序运行的时间。
import edu.princeton.cs.algs4.StdIn; import edu.princeton.cs.algs4.StdOut; import edu.princeton.cs.algs4.Stopwatch; public class ThreeSum { public static int count(int[] a) { int N = a.length; int count = 0; //三重的for循环,检查每三个整数组合 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) count++; return count; } /** * 读入全部整数,输出count的值 * 利用StopWatch执行时间监控 * @param args */ public static void main(String[] args) { int[] a = StdIn.readAllInts(); Stopwatch stopwatch = new Stopwatch(); StdOut.println(ThreeSum.count(a)); double time = stopwatch.elapsedTime(); } }
测试数据能够用愈来愈大的输入来运行。每次将输入的大小翻倍,程序会运行得更久。经过相似的测试,有时能至关方便和快速地评估程序何时结束。
经过实证得出的数据,能够创建图像,使观察更直观:
(lg以2为底)
使用双对数坐标一般状况下是获得一条直线,这条直线的斜率就是问题的关键。
这个例子(3-SUM 暴力算法)的斜率是3
--经过对数坐标的方法得出公式:lg(T(N)) = blgN + c (可看作 y = b*x + c,其中 y = lg(T(N)),x = lgN)
--经过图中两点可求出b,c值,若是等式两边同时取2的幂,就获得 T(N) = 2^c*N^b, 其中 2^c 为一个常数,可记做 a
由此,从这个模型的观察中咱们就获得了程序的运行时间,经过一些数学计算(在这里是回归运算),咱们就知道得出了运行时间:
T(N) = a*N^b (b为双对数坐标中直线的斜率,同时 b 也是这个算法的增加量级,第三点会讲到)
假设
经过上述的数据分析,咱们得出假设:
运行时间看起来大约是 1.006 × 10^–10 × N^2.999 (秒)
预测
能够运用这个假设继续作预测,只要带入不一样的N值,就能计算出须要的大体时间。
・51.0 seconds for N = 8,000.
・408.1 seconds for N = 16,000.
验证
经过对比 程序实际运行时间(下图) 和 经过咱们的假设模型预测的时间上一步) 能够看出结果很是相近 (51.0 vs ~51.0/408.1 vs ~410.8)
这个模型帮助咱们在不须要花时间运行试验的前提下作一些预测。实际上这个情形中存在幂定律(a*N^b).实际上绝大多数的计算机算法的运行时间知足幂定律。
下边介绍一种求解符合幂定律运行时间中的增加量级值(b)的方法
这里能够经过 Doubling hypothesis 的方法能够快速地估算出幂定律关系中的 b 值:
运行程序,将每次输入的大小翻倍(doubling size of the input),而后计算出N和2N运行时间的比率。主要看下图的后几行运算时间比率,前几行的输入值小,以如今的计算机运算能力处理起来,立方级别的增量级运算速度快也相差无几。
ratio ≈ T(2N)/T(N)
至于为何 0.8/0.1≈7.7 或其余看起来 "运算错误" 相似的状况,是由于图上的运行时间的记录是简化精确到了小数点后一位,实际运算比率值是使用了实际运行时间(精确到小数点后几位)去计算的,因此会出现0.8/0.1≈7.7。
经过不断地进行双倍输入实验,能够看到比率会收敛到一个常数(这里为8),而实际上比率的对数会收敛到N的指数,也就是 b 的值,这里粗暴算法的 b 值就等于3
经过Doubling hypothesis方法咱们又能提出假设:
此算法的运行时间大约是 a*N^b, 其中 b = lg ratio
注意:Doubling hypothesis 不适用于识别对数因子
得出 b 的值后,在某个大的输入值上运行程序,就能求出 a 值。
由此得出假设:运行时间 ≈ 0.998 × 10^–10 × N^3 (秒)
咱们经过做图得出的模型( ≈ 1.006 × 10^–10 × N^2.999 )和咱们经过Doubling hypothesis方法得出的模型是很接近的。
计算机中有不少的因素也会影响运行时间,可是关键因素通常和计算机的型号无关。
关键的因素即为你使用的算法和数据. 决定幂定律中的 b 值
还有不少与系统相关的因素:
以上全部因素,包括关键因素,都决定了幂定律中的 a 值
现代计算机系统中硬件和软件是很是复杂的,有时很难得到很是精确的测量,可是另外一方面咱们不须要像其余科学中须要牺牲动物或者向一颗行星发射探测器这些复杂地方法,咱们只须要进行大量的实验,就能理解和获得咱们想要知道的影响因子(的值)。
经过观察发生了什么可以让咱们对性能做出预测,可是并不能帮助咱们理解算法具体作了什么。经过数学模型更有利于咱们理解算法的行为。
咱们能够经过识别全部的基本操做计算出程序的总运行时间。
致敬一下,Don Knuth 在二十世纪60年代末便提出和推广了运行时间的数学模型:sum(操做的开销 * 操做执行的频率)
基于 knuth 研究得知,原则上咱们可以得到算法,程序或者操做的性能的精确数学模型。
基本操做的开销通常都是一个取决于计算机及系统的常量,若是想要知道这个常量是多少,能够对一个基本操做运行成千上万的实验的方式算出。好比能够进行十亿次的加法,而后得出在你运行的计算机系统上进行 a + b 的基本操做花费大概 2.1 纳秒
为了方便创建数学模型,绝大多数的状况下咱们只要 假定它是某个常数 cn (n:1,2,3...) 就能够。
下图罗列了一下基本操做和其开销
关于N:当咱们在处理一组对象时,假设有N个对象,有一些操做须要的时间和N成正比。好比第六行,分配一个大小为N的数组是,须要正比于N的时间,由于在Java中默认吧数组中的每一个元素初始化为0.
还有些运行时间去决定系统的实现,好比链接两个字符串须要的运行时间与字符串的长度(N)成正比,链接字符串并不等同于加法运算
数组中有多少个元素等于0
public class OneSum { public static int count(int[] a) { int N = a.length; int count = 0; for (int i = 0; i < N; i++) if(a[i] == 0) count++; return count; } }
其中几项操做的频率取决于N的输入
数组中有多少对元素等于0
public class TwoSum { public static int count(int[] a) { int N = a.length; int count = 0; for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) if (a[i] + a[j] == 0) count++; return count; } }
额外稍微解释下数据怎么算来的,若是已经了解能够略过如下细致的解释。
j 每次迭代的增量都取决于 i 的值,由于 j 被初始化为 i + 1
便于理解能够用具体数值带入:
假设 N = 5
当 i == 0 时,i 递增到 1,递增了 1 次;j 从 1 递增到 5,递增了4次;i 和 j 一块儿递增了 5 次
当 i == 0 时,i 进行了 1 次 i < N 的比较,j 进行了 5 次 j < 5 的比较,i 和 j 一块儿进行了 6 次比较
将具体泛化:
a) < 比较 : 离散求和公式:0 + 1 + 2 +...+ N + (N+1) = ½(N+1)(N+2)
即当 i == 0 时,j < N 的比较会进行 N 次,所以总的来讲,i 的第一次迭代中**i和j**一块儿有 N + 1 次比较操做 然后 i 递增,对于 i == 1 的下一次迭代,j < N 进行了 N - 1 次,在i的第二次迭代中,**i和j一块儿**有N次比较操做 即 i 每加 1,j 都会在上一层比较的基础上少比较一次 直到 i == N, j 再也不进行比较操做,i 和 j 一共有 1 次比较操做 i + j 总共进行 < N 比较操做的频率利用离散求和就是½(N+1)(N+2)
b) == 比较 : 离散求和:0 + 1 + 2 +...+ (N-2) + (N-1) = ½ N (N − 1)
即当 i == 0 时,j 将会迭代 N-1 (从1到N-1) 次 然后 i == 1 时,j 将会迭代 N-2 (从2到N-1) 次 当 i == N 时,j 将不会再迭代,即 0 次结束 即 i 每加 1,j 都会在上一层迭代的基础上少迭代一次 利用离散求和得出 j 的迭代次数为 ½ N (N − 1) j 的 迭代频率与进行“==”比较的操做频率是同样的,所判断相等的操做频率就等于½ N (N − 1)
c) 数组访问 : 假设咱们假设编译器/JVM没有优化数组访问的状况下
每次进行相等比较都会有两次数组访问的操做,因此是½ N (N − 1) * 2 = N (N − 1)
d) 增量{++} : ½ N(N+1) to N^2.,coursera上ppt的½ N (N − 1) to N (N − 1)是错的
Mathematical Models, slide 28, 30, 32. Number of increments should be ½ N(N+1) to N^2.
(参见coursera 课程勘误表Resources--Errata)
当 i == 0 时,i 先进行递增,j 也递增了 N-1 次,所以总的来讲,i 的第一次迭代中**i和j**一块儿有 N 个递增 而后i递增,对于 i == 1 的下一次迭代,j 将递增 N-2 次,在i的第二次迭代中,**i和j一块儿**给出N-1个增量。 一直到 i == N,**i和j一共**只有一次递增 (j 再也不递增) 一样利用离散求和:N +(N-1)+ ... + 2 + 1,**i和j一块儿给出** ½N(N+1)个增量 下限 : ½ N(N+1)(假设计数彻底没有增长,即count没有增长,只有上诉 i 和 j 进行了增量)。 上限 : 咱们假设计数器count在每次循环都增长,count++执行的次数与“等于比较”的次数相同,所以咱们获得 ½ N(N+1) + ½ N(N-1) = N^2
原则上咱们是能够算出这些精确的次数,但是这样太繁琐。图灵大佬1947年就提出了,其实咱们测量计算过程当中的工做量时不用列出全部细节,粗略的估计一样有用。其实咱们只须要对开销最大的操做计数就OK了。因此如今咱们也这么干。咱们选出开销最大的基本操做,或者是执行次数最多的、开销最大的、频率最高的操做来表明执行时间。
咱们假设运行时间等于 常数*操做的执行时间,在 2-SUM 例子中,
咱们选择访问数组的时间 (c*N(N − 1)) 表明这个以上算法的运行时间。
-- 估算输入大小为 N 的函数的运行时间(或内存)
-- 忽略推导式子中的低阶项。使用 tilde notation (~ 号)表示:
a) 当 N 很大时,咱们只须要关注高阶项的开销
b) 当 N 很小时,虽然低阶项不能忽略,可是咱们更无需担忧,由于小 N 的运行时间原本就不长,咱们更想要对大 N 估计运算时间
如图,当 N 很大时,N^3 远比后边的 N 的低阶项要大得多,大到基本不用关注低阶项,因此这些式子都近似为 (1/6)N^3
经过图形能够看出低阶项真的没太多影响
波浪号的含义:f(n) 近似于 g(n) 意味着 f(n)/g(n)的极限等于 1
简化统计频率后,咱们能够这么样的表示:
是否是看起来更微妙,更清爽~
结合两种简化,咱们就能够说 2-SUM 须要近似 N^2 次数组访问,并暗示了运行时间为 ~c*N^2 (c 为常数)
利用开销模型和 ~ 尝试对 3-SUM 问题进行分析
public class ThreeSum { public static int count(int[] a) { int N = a.length; int count = 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) count++; return count; } }
开销最大的就是这句了:if (a[i] + a[j] + a[k] == 0),咱们能够说 3-SUM 问题须要近似 ~ ½ n3 次数组访问,并暗示了运行时间 ~½ c*n3 (c 为常数)
为了不部分蒙圈现象,解释下为何是1/6 N^3 和 1/2 N^3
a) 1/6 N^3 这个值仍是离散求和得出的,能够参考 2-SUM. 就是又多了一层loop. 建议利用计算器或者工具去计算
Maple 或者 Wolfram Alpha
b) 由于 1/6 N^3 是 equal to compare 的次数,不是数组访问的次数。
每次在执行 equal to compare 都有 3 次数组访问,因此是 1/6 N^3 * 3 = 1/2 N^3
精确的模型最好仍是让专家帮搞定,简化模型也是有价值的。有时会给出一些数学证实,可是有时候引用专家的研究成果,利用数学工具就能够了。简化后咱们就不用去计算全部操做的开销,咱们选出开销最大的操做乘上频率,得出适合的近似模型来描述运行时间。精确一点的数学模型以下:
costs:基本操做的开销,常量,取决于计算机,编译器
frequencies:操做频率,取决于算法,输入大小(即 N 的大小)
如下增加量级同增加阶数一个意思。
增加量级能够看作是函数类型,如是常量,线性函数,指数函数,平方,立方,幂函数等。
通常分析算法时咱们不会遇到太多不一样的函数,这样咱们能够将算法按照性能随问题的大小变化分类。
通常算法咱们都能用这几个函数描述:
当咱们关注增加量级时,咱们会忽略掉函数前面的常数。好比当咱们说这个算法的运行时间和 NlogN 成正比,等同于咱们假设运行时间近似 cNlogN (c 为常数).
上图为双对数坐标图,从图中能够看出若是:
以上两种算法都是咱们想要设计的算法,它们可以成比例适应问题的规模。
综上所诉,咱们研究算法是,首先要保证这些算法不是平方或者立方阶的。
增加阶数类型实际上就源于咱们写的代码中的某些简单模式。下图使用翻倍测试(参考上边 Doubling hypothesis 内容)得出算法运行时间随问题大小翻倍后增加的翻倍状况。某些增加量级对应的代码模式以下:
若是有某种循环:
经过上述分析,咱们在设计处理巨大规模输入的算法的时候,通常都尽可能把算法设计成线性阶数和线性对数阶数。
为了展现描述算法性能的数学模型的创建过程,下边以 binary search 二分查找为例
目标:给定一个有序整数数组,给定一个值,判断这个值在这个数组中是否存在,若是存在,它在什么位置
二分查找:将给定值与位于数组中间的值进行比较
以下图,查找 33,首先和 53比较,33<53, 因此若是33存在,那么就会在数组的左半边,而后递归地使用一样的算法,直到找到,或确认要查找的值不在给定数组中。下图展现二分查找的过程(使用了3个指针 lo, hi, mid)
初始化 lo 指针指向 id[0], hi 指针指向 id[n-1], mid 指针指向 id[mid]
33<53, hi指针向左移动到mid的前一位
33>53, lo 指针向右移动到mid的后一位
33<43, hi 指针移动到 43 以前,也就是数组中 33 的位置,此时只剩下一个元素查看,若是等于 33,则返回 index 4, 若是不等于 33,则返回 -1,或者别的形式说明要查找的定值不在数组中
此算法的不变式:若是数组 a[] 中存在要寻找的关键字,则它在 lo 和 hi 之间的子数组中, a[lo] ≤ key ≤ a[hi].
public static int binarySearch(int[] a, int key) { int lo = 0, hi = a.length - 1; while (lo <= hi) { //why not mid = (lo + hi) / 2 ? int mid = lo + (hi - lo) / 2; //关键值与中间值是三项比较(<,>, ==) if (key < a[mid]) hi = mid - 1; else if (key > a[mid]) lo = mid + 1; else return mid; } return -1; }
定理:在大小为 N 的有序数组中完成一次二分查找最多只须要 1 + lgN 次的比较
定义:定义变量 T(N) 表示对长度为 N 的有序数组的子数组(长度<=N)进行二分查找所须要的比较次数
递推公式(根据代码):T(n) ≤ T(n / 2) + 1 for n > 1, with T(1) = 1.
程序将问题一分为二,因此T(n) ≤ T(n / 2) 加上一个数值,这个数值取决于你怎么对比较计数。这里看作二向比较,分红两半须要进行一次比较,因此只要 N>1, 这个递推关系成立。当 N 为 1 时,只比较了 1 次。
裂项求和
咱们将递推关系带入下面公式右边(即 <= 号右边)求解,
若是T (n) ≤ T (n / 2) + 1 成立,则 T (n / 2) ≤ T (n / 4) + 1 成立...
这个证实虽然是证实在 N 是 2 的幂的时候成立,由于并无在递推关系中明确 N 是奇数的状况,可是若是把奇数状况考虑进来,也可以证实二分查找的运行时间也老是对数阶的。
基于这个事实,咱们可以对 3-SUM 问题设计一个更快的算法:
(基于增加量级与二分查找应用)
Java 实现:
import java.util.Arrays; public class ThreeSumFast { // Do not instantiate. private ThreeSumFast() { } // returns true if the sorted array a[] contains any duplicated integers private static boolean containsDuplicates(int[] a) { for (int i = 1; i < a.length; i++) if (a[i] == a[i-1]) return true; return false; } /** * Prints to standard output the (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0}. * * @param a the array of integers * @throws IllegalArgumentException if the array contains duplicate integers */ public static void printAll(int[] a) { int n = a.length; Arrays.sort(a); if (containsDuplicates(a)) throw new IllegalArgumentException("array contains duplicate integers"); for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) StdOut.println(a[i] + " " + a[j] + " " + a[k]); } } } /** * Returns the number of triples (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0}. * * @param a the array of integers * @return the number of triples (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0} */ public static int count(int[] a) { int n = a.length; Arrays.sort(a); if (containsDuplicates(a)) throw new IllegalArgumentException("array contains duplicate integers"); int count = 0; for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) count++; } } return count; } /** * Reads in a sequence of distinct integers from a file, specified as a command-line argument; * counts the number of triples sum to exactly zero; prints out the time to perform * the computation. * * @param args the command-line arguments */ public static void main(String[] args) { In in = new In(args[0]); int[] a = in.readAllInts(); int count = count(a); StdOut.println(count); } }
基于搜索的算法:
若是找到- (a[i] + a[j]),那么就有 a[i], a[j] 和 - (a[i] + a[j]) 三个整数和为 0
运行时间的增加阶数: N^2 log N.
第二步: 二分查找使用 N^2 log N
for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) count++; } }
第2步进行屡次二分搜索。多少次? N ^ 2次。二分查找须要log(n)时间 (请参考概述中最后一个表和回顾二分查找的内容)。 所以,循环须要(N ^ 2 * log(N))时间。 应该注意循环在排序后发生。不在排序过程当中发生。因为操做一个接一个地发生,咱们添加了运行时间。不是成倍增长。 **总运行时间是这样的**: (N ^ 2)+(N ^ 2 * log(N)) **因为忽略了较低阶项**,所以算法只有最重要的项的增加顺序: (N ^ 2 * log(N))
一般,更好的增加阶数意味着程序在实际运行中更快。
为了更有说服力,通常状况下不考虑上下限问题,运行时间为最坏状况下的时间复杂度 (算法理论内容)
增加量级在实际运用在是很是重要的,它直接反映了算法的效率,近年来人们针对增加量级也作了不少研究。
一个不一样的输入可能会让算法的性能发生巨大变化。咱们须要从不一样的角度针对输入的大小分析算法。运行时间介于最好状况与最坏状况之间。
Best case:最好状况,算法代价的下限(lower bound on cost), 运行时间老是大于或等于下限。
Worst case:最糟糕的状况,算法代价的上限(Upper bound on cost), 运行时间不会长于上限。
Average case:平均随机状况,将输入认为是随机的
通常的,即便输入变化很是大,咱们也可以各类状况进行建模和预测性能
Ex 1. 如上边的 3-SUM 问题:
经过“暴力算法”,数组的访问次数为
Best: ~ ½ N^3
Average: ~ ½ N^3
Worst: ~ ½ N^3
其实各类状况的低阶项是不同的,可是由于咱们利用了简化方法忽略了低阶项(回顾数学表示的简化内容),因此3种状况下的数组访问几乎是同样的。使用近似表达时,算法中惟一的变化就是计数器 count 增长的次数。
Ex 2. 二分查找中的比较次数
Best: ~ 1 常数时间,第一次比较结束后就找到了关键字
Average: ~ lg N
Worst: ~ lg N
应对不一样的输入,咱们有不一样的类型分析,可是关键是客户要解决的实际问题是什么。为了了解算法的性能,咱们也要了解这个问题。
实际数据可能与输入模型不匹配怎么办?
方法1:取决于最坏状况下的性能保证,保证你的算法在最坏状况下运行也能很快
若是不能保证最坏状况,那么就考虑随机状况,依靠某种几率条件下成立的保证
方法2:随机化,取决于几率保证。
(排序在后几个星期有谈论到)
对于增加量级的讨论引出了对算法理论的讨论
新目标
肯定问题的“困难性”
方法
用增加量级对最坏状况进行描述
分析的目标是找出“最优”算法
最优算法
如何使用这三个符号对算法按照性能分类?
目标:肯定问题的“难度”并开发“最优”算法。
上限:O(g(N)) 问题难度的上限取决于某个特定的算法
1-SUM 问题未知的最优算法的运行时间是 O(N):
下限:Ω(h(N)) 证实没有算法能够作得比 Θ(h(N)) 更好了
1-SUM 的未知最优算法的运行时间是 Ω(N)
最优算法:
对于简单问题,找到最优算法仍是比较简单的,但对于很复杂的问题,肯定上下限就很困难,肯定上下界吻合就更加困难。
目标
暴力算法分析
上限: 问题难度的上限取决于某个特定的算法
3-SUM 的最优算法的运行时间为 O(N^3)
但若是咱们找到了更好的算法
上限: 一种特定的改进算法
下限: 证实没有别的算法能够作得更好
可能你们仍是对Omega Ω 符号有点困惑。 Omega只显示算法复杂度的下限。 3-SUM 算法须要检查来自某个数组的全部元素,所以咱们能够说,该算法具备 Ω(N) 复杂度,由于它至少执行线性数量的操做。事实上,操做总数是更大的,所以实际最优算法确定是 ≥ Θ(N) 的,记做 Ω(N)
对于 3-SUM 问题没有人知道更高的下界,其实咱们如今就能看出,处理 3-SUM 问题确定是要用超过 Θ(N) 的时间的,可是咱们却不能肯定多出多少,就是不知道比 Θ(N) 更高的下界是多少。
当有人证实更高的下限时,也是赞成没有算法能够作得比前一个下限更好的前提下提出新的下界。可是他们会作出了更强有力的陈述,特别是证实没有算法能够实现比他们刚才证实的新下界更好,以此来提升原来的下界,定义一个新的下界。
新的下限可能仅略高于先前的下限,或者可能显着更高。提升下界每每都不是很容易。谈论如何提升下界这也不是本文的重点。
算法理论中的一个开放问题:
·3-SUM 有最优算法吗?咱们不知道
·3-SUM 问题是否存在一个运行时间小于 O(N^2) 的算法?咱们没法肯定
·3-SUM 比现行的下界更高的下界是什么,上面已经谈论过了,咱们也还不知道
咱们不知道求解 3-SUM 问题的难度
因此人们更倾向于研究持续降低上界,也就是设法提升算法在最坏状况下的运行时间来了解问题的难度,并获得了不少最坏状况下的最优算法。
值得注意的是:有不少人错把 big-Oh 分析结果当作了运行时间的近似模型,其实 big-Oh 应该是这个问题运行时间的上界,不是运行时间的近似模型。
咱们使用 ~ 来表示算法运行时间的近似模型。当咱们谈论到运行时间的上界就使用 big-Oh.
运行时间和程序的内存需求都会对算法的性能有所影响,下边是对内存需求的简单讨论。
从根本上讲咱们就是想知道程序学要多少比特(bit),或者多少字节(byte)
Bit: 0 or 1 Byte: 8 bites Megabyte (MB) 2^20 bytes Gigabyte (GB) 2^30 bytes. 32-bit machine: 32 位系统,指针是 4 个字节, 64-bit machine: 64 位系统,指针是 8 个字节,这使得咱们可以对很大的内存寻址,可是指针指针也使用了更大的空间。有些 JVM 把指针压缩到 4 bytes 来节省开支。
内存使用和机器还有硬件实现有很大的关系,可是通常状况都是如图所示
Boolean 虽然只用了 1 bit,但系统仍是分配了 1 byte 给它
数组须要额外空间 + 基本类型空间开支(参考左表) * 元素个数(N)
二维数组须要的空间下图用近似值表示, ~ 2MN 能够理解为 char 基本类型开销是 2 bytes,char [M] [N] 近似用了 2MN bytes 的内存
Object overhead 对象须要的额外空间. 16 bytes. Reference 引用. 8 bytes. Padding 内置用来对齐的空间. 对齐空间能够是 4 bytes 或者是其它,对齐空间的分配目的是使得每一个对象使用的空间都是 8 bytes 的倍数
下图是一个日期对象的内存占用量例子
数据类型值的总内存使用量:
例子:用了多少字节?
使用上边的基本知识能够算出 B
总共 8N + 88 ~ 8 N bytes.
Version 0: Try each flow from the bottom. The first floor that the egg breaks on is the value of T. Version 1: Using the binary search.Firstly, try floor T/2. If the egg breaks, T must be equal to T/2 or smaller. If the egg does not break, T must be greater than T/2. Continue testing the mid-point of the subset of floors until T is determined. Version 2: Start test at floor 1 and exponentially grow (2^t) floor number (1, 2, 4, 8 ... 2^t)until first egg breaks. The value of T must be in [2^(t-1), 2^t). This step costs lgT tosses. Then in the range got from last step can be searched in ~lgT tosses using the binary search. Two step will cost ~2lgT tosses. Version 3: Test floors in increments of sqrt(N) starting from the first floor. {e.g: {1, sqrt(N), 2*sqrt(N), 3*sqrt(N)...t*sqrt(N)...}. When the egg breaks on t, test floor from (t-1)*sqrt(N) and increment by each floor. The remaining sqrt(N){e.g [(t-1)*sqrt(N), t*sqrt(N))} tests will be enough to check each floor between floor t-1 and t. The floor that breaks will be the value of T. Version 4: Start test at floor 1 in increments of t^2 (e.g {1,4,9...t^2..N}), When the egg breaks on t, test floor from (t-1)^2+1 and increment by each floor.