《编程珠玑》读书笔记 part2

第二部分 性能


第6章 程序性能分析


用一个实例说明了提升程序执行效率的几种经典方法:算法和数据结构,算法调优,数据结构重组,代码调优,硬件。其中的“调优”一般指用细节的改变来获得相对较小的加速,是一个微调过程。注意数据结构和算法的加速并不一定是独立于硬件的,例如超级计算机的管道体系结构非常适合线性的数据结构,而树结构相反未必是最佳选择;


优化程序的方法设计多个层面,如果仅需要较小的加速,就对效果最佳的层面做改进,例如不要下意识地直接选择改进算法,在有些时候说不定改进硬件是性价比最高的;如果需要较大的加速,则可以对多个层面进行改进;接下来的三章分别介绍了粗略估算、算法设计和代码调优三种优化方法,本部分最后一章讨论空间效率问题。
--------------------------------


第7章 粗略估算


本章讨论“粗略估算”问题,这对于多数从业工程师来说是谋生的必备技能。在进行粗略估算时需要一些基本的技巧和原则,例如:
· 两个答案比一个答案好——尝试利用不同条件,从不同途径获得估算结果,比进行相互验证;
· 利用量纲和数字技巧进行快速验证;
· 记住经验法则。
     - 例如所谓“72法则”,假设以年利率r%投资一笔资金y年,那么如果r*y = 72, 则你的投资差不多会翻倍。(用数学语言说 (1 + r%)^(72/r) ≈ 2)由此,我们知道在盘子中培养的以3%速率增长的菌群大约每天都会翻倍,2天后两次翻倍,那么10天后就大约增长了1000倍。
     - 另一个例子叫做“π秒就是一个纳世纪”,所以如果估算出一个程序运行时间为1e7s,我们就知道运行这个程序需要为结果等待几个月了;
     - Little定律:系统中物体的平均数量等于物体离开/进入物体的平均速率乘以每个物体在系统中的平均逗留时间(假定系统中物体数量是稳定的)。由此,如果我们在排队等待进入一个可以容纳60人并且每个人平均逗留3小时的夜总会时,如果看到队伍前面还有20人,我们就知道还需要等待一个小时啦;
· 勤于实践
     - 多思考这样的问题:一个箱子中能装多少个塑料球?每天排队等待午餐的的时间大约是多少?密西西比河每天流出多少水?……
     - 多利用小实验建立自己对数据的感觉,比方说ping 一个美国的服务器一般要多久?再比方说在系统的任务管理器中可以看到系统CPU和内存的使用情况,打开“资源监视器”可以看到明细,利用这些工具我们可以估计例如自己的机器通常有多少内存是空闲的等参数;
· 【存疑】书中说,当使用malloc函数进行分配时,虽然一个struct可能只需要8Byte空间,但其实内部每个节点都多占用了40Byte的空间,附录C中还给出略详细的分析,说连续10次分配的指针大约相差48Byte,所以不能直接按照内存容量去估计;但我的实测结果是,用malloc或new连续申请几个int空间,所得指针确实只相差4,不知道是否是由于现代编译器优化后的结0果?但这里面仍然蕴含了一个参数估计的原则,为了补偿我们的知识局限,最好能尽量低估程序的性能(2倍、4倍或6倍),这样可以得到比较高的可靠性;


如果我们统计了连续百万次开方运算需要0.2s,那么一次开方运算真的需要200ns么?不是的,实际的运算可能比这慢许多,因为开方函数缓存了一些最近的参数作为计算的起始值;(在第5、9章中各有一个类似的问题,也是考虑了缓存对性能的优化)


--------------------------------


第8章 算法设计技术


大O分析法的缺点是,即便我们知道了算法的时间的量级,我们仍然不知道对于特定输入,需要的时间究竟是多少;但是这个缺点可以被另外两个优点所弥补:易于实现,渐进估计对于粗略估算已经足够了。


这一章以一个经典问题说明了算法设计中的常用技巧:
在一个长度为n的浮点数向量中,如何找到和最大的连续子向量?也即找到最优的 i,j,使得x(i) + x(i+1) + ... + x(j) 最大。
· 傻瓜思路:O(n^2)个子序列,每个用O(n)时间求和,遍历选取最大值,总时间O(n^3);
· 优化1:统计O(n^2)个子序列的和,但注意sum(i, j) = sum(i,j-1) + x(j) ; 所以只要保存每个子序列的和的计算结果,则下一个子序列可以利用之前的结果进行一次加运算得到,总时间O(n^2);
· 优化2:先遍历一遍计算累加部分和,即a(i) = x(1) + x(2) + ... + x(i);接下来每个子序列的和只需要一次减法运算:sum(i,j) = a(j) - a(i-1);总时间O(n^2);
· 优化3:分治。将向量分为等长的两段,最大和可能在第一或第二段中,也可能是跨越两段边界的一个子序列——如果是跨越边界的子序列,只需把第一段的正数后缀和第二段的正数前缀相加即可;这样 T(n) = 2T(n/2) + O(n) ,所以T(n) = O(nlogn);
· 优化4:动态规划。考虑x(1 .. i )段的最大子序列,它有可能是包含x(i), 即它是x(1, .. ,i) 的一个后缀,也可能仅由x(1,..,i-1)中的元素构成;x(1.. i )的最大子序列为如下两个值的最大值,其一是x(1 .. i-1 )的最大子序列M(i-1),其二为终止于x(i)的最大子序列M_post(i);后者如下更新:M_post(i) = max(M_post(i-1)+x(i) , 0); 即,如果加上x(i)后,后缀已经小于0,则我们认为此时合适的后缀长度为0,和为0,下一步再重新开始统计。注意,假设M_post(i-1)对应的最大后缀为x(m .. i-1), 而sum(x, .. i-1 ) + x(i) < 0, 那有没有可能将后缀长度缩短到一个非零的长度从而获得一个非负的结果呢?答案是否定的,因为如果sum(n, .., i )>=0, 且 n > m, 那么可以知道 sum(m, .. n-1) < 0, 那么在M_post(n-1)的计算时就应该已经被抛弃了。由此知道,不仅是我们选出的最大后缀非负,这个最大后缀的所有前缀也都非负。 本算法的总时间为O(n);此时在看看O(n^3)的原始方法有多么笨拙吧!


提高时间效率技巧小结:
· 保存状态,避免重复计算;
· 预处理数据以便于处理(如优化2中的累加和);
· 分而治之;
· 动态规划(扫描);
· 了解时间的下界;(例如在本例中,时间下界就是O(n), 所以不用再期待常数时间算法啦)


--------------------------------


第9章 代码调优


代码调优是针对具体问题对代码进行的微调,但有时仍然能够起到令人惊异的优化效果。调优的第一个步骤是对程序时间进行准确的度量,寻找到真正的瓶颈,并力图解决。调优的常见策略如下:


· 简化关键操作。例如,通常取余操作(%)的时间代价很高,在循环数组的访问中,如果把 j = ( j + m ) % N; 改为 j += m; if( j >=N ) j-=N; 化取余为比较和减法,这样通常会有明显的效率提升;当然另一方面,如果m很大,超过了缓存范围,那么程序的瓶颈就转移到了存储器访问上,此时上述调优策略就不再发挥作用了。(Again,我们再次看到了存储器结构对程序设计的影响,类似的例子在第5章和第7章存在)


· 内联函数。简单函数调用写成宏操作会提高效率,但有时也有例外,#define max(a,b) ((a)>(b):?(a):(b)) 中,如果a和b是两个函数调用的话,那么使用宏定义的max时,a和b函数各被调用了两次,这样不仅效率降低,有时甚至会造成错误。更好的方法是使用C++中定义的内联函数。


· 合并测试 & 展开循环。例如在顺序搜索中,
for( i  = 0; i < N;  i ++)
     if( x[i] == t)
          return i;
如果写成
x[n] = t;//设置哨兵;
for( i = 0; ; i += 8)
     if( x[i       ] == t ) {             break;}
     if( x[i + 1] == t ) { i += 1;  break;}
     if( x[i + 2] == t ) { i += 2;  break;}
     ……
     if( x[i + 7] == t ) { i += 7;  break;}
if(i==n) return -1;
else return i;
在这个变化中,首先把 i

· 用等价的数学表达式替代复杂数学运算;


在有关二分搜索优化的例子中,综合运用了展开循环、合并测试以及数学表达式替换等多种方式,完成了优化。
--------------------------------


第10章 节省空间


空间一方面值计算机程序占用的(数据)存储器资源,另一方面值代码长度,也即指令数目;
前者的主要优化策略:不存储、直接计算(时间换空间);为稀疏数据设计特别的数据结构;数据压缩;动态分配空间;定期回收垃圾;
后者的主要优化策略:定义函数;解释程序(不懂);翻译成机器语言;


--------------------------------
(待续)

你可能感兴趣的:(《编程珠玑》读书笔记 part2)