《编程珠玑》读书笔记 part1

Programming Pearls 第2版 作者 Jon Bentley.

第一部分 基础
——————————————————————————————————–
第1章 开篇

这一章探讨了一个经典的问题,即所谓的磁盘文件排序或“外排序”。
根据这里的(http://blog.csdn.net/maozefa/article/details/2023395)介绍,对磁盘文件的排序一般有3种方法:

1、将磁盘文件装入内存排序,将排序结果保存到新的文件,这适用于很小的(64K以内)、不需要经常索引的文件;
2、对磁盘文件按关键字进行分块排序后,形成一个索引文件。块的大小一般为512K,常采用B+树或者B-树算法,这种方法适用于需要经常索引的磁盘文件,如DBF文件;
3、把磁盘文件分片排序后,形成很多排序片文件,然后将这些排序片文件合并起来,输出为一个排序文件,这种方法适用于很大的、但又不需要经常索引的磁盘文件。

其中B树的算法仍然需要钻研,对于合并排序倒是相对比较清楚,大致的办法是:
1) 遍历文件一次,在此过程中,不断读入小段的内容到内存中,直接排序(例如使用快排算法),然后输出成若干个有序的文件;
2) 两个一组地处理这些有序的文件,把两个有序的序列合并为一个有序的序列,为了做到这一点,每次最多只需要从一个文件中读入一个数据到内存,并输出一个数据到一个新的文件;
3) 递归地进行下去,最终合并为一个完整的有序文件,排序过程结束。
这种方法的优点当然是可以处理很大的文件,不受内存限制,但缺点之一是虽然原先的文件只需被遍历一次,但排序过程中生成了许多中间的工作文件,仍然有密集的读写文件操作。

为了进一步克服这种缺点,往往需要仔细地分析问题,寻找其中被我们忽视的条件。(这也是本章的主题之一:尝试解决问题之前必须先明确问题)

例如,通常我们待排序的数据是有一个取值范围的,例如[0,N],如果有这个先验的知识,那么就可以采用经典的分桶方法,将数据分成[0,M-1],[M,2M-1],…,[kM,N]这样一系列区间,其中的M可以设置为足以被内存装下的量级。当然这种做法其实还需要其他的条件:原有的数据是不重复的(从而每个小区间中至多有M个数据)或者数据大致是均匀分布在[0,N]范围内的。

由此引出了一个问题是,如何用恰当的方法获得大致均等的数据分桶,在很多场合中可以用hash函数配合mod函数得到理想的方法,比方说如果希望从大量的url数据库中找出出现频率最高者,那么可以用一个理想的hash函数作用于每个url,再按模100的余数划分,这样就得到了100个均匀的文件(因为已经假设了hash函数的质量),更重要的是,我们可以肯定相同的url一定出现在同一个文件中,从而频度的统计可以顺次把各个文件读入内存而实现。类似地,如果数据是手机号码,那么直接按尾号分类就基本可以达到要求了。当然这种划分很适合于“寻找重复元素”的操作,对于排序问题显然是不合理的。

继续前面的话题,如果再进一步增加条件:待排序的数据都是整数,那么排序的方法可以更加优化,最直接的方法就是用所谓的“位图”,用某一位是0或1来表示相应的整数是否存在,那么只需读入文件,生成位图(暂且假定内存可以存下这个位图——注意,500MB的位图就可以表示全部32位的整数了哦),再按顺序输出对应的整数,就得到了有序文件。由此可见明确问题的所有条件对于优化解法有多大的益处;如果你不能描述清楚自己要解决什么问题,那么基本可以肯定你是无法出色地将其解决的。

摘录:本章所描述的实例中,程序员的主要问题与其说是技术问题,不如说是心理问题:他不能解决问题,是因为他企图解决错误的问题。

——————————–

第2章 啊哈!算法

这一章用三个小问题说明了算法设计在解决问题的过程中发挥的神奇作用。

问题1:给定一个文件,其中包含大量的32位整数,如何有效找到一个不存在在文件中的32位整数(假定至少有一个整数满足条件)。延续上一章排序问题的思路,可以考虑使用位图方法:建立一个位图,标记每一个整数存在与否,然后遍历位图,输出第一个标记为0的位置对应的整数即可。当内存不足时,这种方法就遇到了困难。因为整数文件是无序的,如果内存不足以保存全部位图,那么恐怕需要另一个文件保存位图,每次读入整数都需要再访问位图文件执行写入操作,这样我们就需要N次读,N次写以及O(N)量级的读操作已完成位图的顺序搜索,当然其中的N是全部整数的个数。

欲对此进行优化,首先需要注意到,这是一个搜索问题,而不是排序——我们真的需要把所有整数都记录下来么?

答案是否定的。利用经典的二分搜索可以解决这个问题:首先考察所有整数的最高位,按照是0或1可以分为两类,如果有一类中整数的个数比另一类少,则我们可以肯定这一类中一定包含有我们希望寻找的缺失整数;如果两类个数相同,则随便选一类就可以了(因为假定最高位为0的一类没有确实整数,那它的个数一定超过了2^31, 那么最高位为1的一类的个数也超过了2^31, 由此知道这一类也没有确实的整数,那就是说所有整数都存在,矛盾。)。选定之后,可以再在子类中考察第二位是0还是1,进一步分类,这样每一次划分都能够把问题分解为原先一半的规模,并且其中存在缺失整数。由此,读写操作需要进行N + N/2 + N/4 + …次,直到剩余的部分可以读入内存为止,而这个序列的和一定小于2N,一旦读入内存,后面可以继续使用二分搜索,但速度就快多了,所以比O(N)量级的读操作的顺序搜索高效得多。

由本例依然可以看出清楚需要解决的问题很重要,因为我们需要搜索而不是排序,所以不需要考察全部元素。这个例子是一个二分搜索的变形,理论上二分搜索的输入应该是有序的,而本体利用了输入的都是整数这一个特点,直接观察高位的值就对数值的大小进行了区分。

问题2:把一个长度为n的字符串向左循环移位i位,有没有可能实现线性时间内的原地操作?嗯,这道题直接看了答案,而结果实在是太惊艳了。设一个字符串左边i位为串A,剩余的n-i位为串B,那么循环移位遇得到的结果是BA,注意到将一个字符串逆序可以在线性时间内原地完成,那么直接利用BA = (A_r B_r)_r就可以了,其中_r表示对相应字符串的逆序。这道题的解法实在过于美妙和神奇,以至于很难推测应该怎样思考才能获得这样的方法,只可以说至少要对逆序这样的基本操作很熟悉才可以吧。至于怎样从移位问题的思考过程中想到逆序,就实在不得而知了。

这道题远远不仅是益智游戏,实际中经常需要交换两个相邻并且大小不同的内存块,例如把文档中的一段话拖动到另一位置,而这种场合一般对时空均有较严格的要求。

问题3:给定一个英语词典,如何找到其中所有的变位词集合:如果一个单词可以由另一个单词变换字母顺序而得,那么它们互为变位词。对于此题,最直接的想法是比较任意两个单词,并判断它们是否互为变位词,而这样做显然不可行,因为比较次数为O(n^2),而判断变位词最直接的想法是对一个单词遍历所有字母排列,观察是否与另一个单词相同。

一个很重要的优化是在判断变位词部分,比方说只需要直接统计每个单词中出现的字母组成——用一个26区间的直方图去表示单词,然后比较两个单词的直方图是否相同就可以了。当然26区间的直方图还是有点浪费,因为每个单词中一般都只出现了少数的几个字母,所以可以只记录出现了的单词,例如book就是1b1k2o,或者写成bkoo. 而这就是单词的字典序排列。所以,我们可以直接把每个单词的字母按字典序排列一遍,作为每个单词的“特征”,再据此进行分类。分类的方法就是把所有特征进行排序,相同的特征一定会出现在相邻位置,搞定!

由此想到了ZJU ACM题集的Problem1003 Crashing Balloon(http://acm.zju.edu.cn/onlinejudge/showProblem.do?problemCode=1003)在这道题中我就非常实在地按照题意对整数进行了分解,完全没有想到可以对1到100的因子逐个进行考虑,这样一个3叉树的先根遍历(或者说图的DFS)就轻松解决了问题(http://www.fookwood.com/archives/197),而且无论时间、空间效率还是代码长度都秒杀了我的傻瓜式算法。

摘录:优秀的程序员都有点懒,他们坐下来并等待灵机一动的出现而不急于使用最开始的想法编程。

——————————–

第3章 数据决定程序结构

注:原文本章标题为 Data structures programs,中间的structure是动词,实在是妙不可言,不过翻译成中文就索然无味了。

本章介绍合理的数据结构带来的益处。我们都知道数据结构和算法结合对程序起到的加速作用,例如堆排序以及平衡二叉树的搜索等等。至少从本章的内容中看,我觉得如果把数据结构和算法分开考虑,那么优化数据结构的主要益处在于减少代码长度,提高编写效率。对于这一点书中给出了一个有趣又常见的例子,很多网站都会把这样的信息发送给每个用户:

Welcome back, Jane! We hope that….

这段文字中,Jane是用户的名字,因人而异,除了类似的一些个人信息外,大部分文字都完全相同。当然如果实现,可以采用如下伪代码:

print “Welcome back, “;
print custom.name;
print “! We hope that”;

但这样程序将变得很琐碎且可读性差。更好的方法是,建立如下模板:

Welcome back, $1 ! We hope that….

并写一个对应的函数生成最终的文本,这个函数逐次读入模板,如果读入的词以”$”开始,那么就进入一个与用户相关的数据结构custom中选择内容输出,否则就输出读入的词,这样程序就变得简单且美观了许多。

摘录:程序员在节省空间方面无计可施时,将自己从代码中解脱出来,退回来并集中心力研究数据,常常能有奇效。(数据的)表示形式是程序设计的根本。(《人月神话》)

——————————–

第4章 编写正确的程序

本章的核心就是一句话:总是保持对代码的正确理解,不要理会那种“只要能让程序工作,怎么改都行”的催促。
这一章介绍了循环不变式的概念,以及通过初始化、保持、终止三步验证法,在实际编程过程中可以考虑插入assert断言,以确保对程序的准确理解。

——————————–

第5章 编程小事

前面四章依次介绍了如何定义正确的问题、选择恰当的算法和数据结构、写出优雅正确的伪代码;本章则简要介绍最后一个环节:编程。在程序中加入“脚手架”(scaffolding)以做到对函数准确地把握和理解,我个人对脚手架的理解就是对程序功能的一个简要分割,可以在适当位置加入assert(bool)断言,以确保我们对程序的理解是正确的,以二分搜索为例,在输入端可以测试用于搜索的数组是否有序,如果不是直接报错;这样若干个分段就可以把程序错误限定在较小的范围内,对于大型编程是个十分有益的习惯。这一章以二分搜索为例给出了编写小的以及稍大规模的测试程序的方法,最后给出了对程序进行计时的例子。

计时过程中有一个很有意思的小问题:我们建立一个长度为n的有序数组,然后顺次搜索第1、2、3…个元素,统计总时间;改变n以观察时间随n的变化,以估计时间常数(t = c·logn),这种方法有何问题么?答案是有的。顺次访问满足空间局部性,充分利用了cache(缓存)的性能:n很大时,cache不能一次性读入整个数组,但相邻的元素在二分搜索过程中的查询路径基本相同,从而需要用到的数据通常在同一个block中。另一方面,在实际应用中,正是因为需要查询的数据可能分布在数组的任意位置,才可能需要用到二分搜索,所以用顺次访问方式测量出的常数c去估计一般问题的时间将会有偏差。

(待续)

点赞