第一章:位图Bitmap
- 输入:所输入的是一个文件,至多包含n个正整数,每个正整数都要小于n,这里n=10^7。如果输入时某一个整数出现了两次,就会产生一个致命的错误。这些整数与其它任何数据都不关联。
- 输出:以增序形式输出经过排序的整数列表。
- 约束:大概有1MB的可用主存,但可用磁盘空间充足。运行时间至多允许几分钟,10秒钟是最适宜的运行时间。
如果主存容量不是严苛地限制在1MB,比如说可以是1MB多,或是1~2MB之间, 那么我们就可以一次性将所有数据都加载到主存中,用Bitmap来做。 10,000,000个数就需要10,000,000位,也就是10,000,000b = 1.25MB。
程序可分为三个部分:第一,初始化所有的位为0;第二,读取文件中每个整数, 如果该整数对应的位已经为1,说明前面已经出现过这个整数,抛出异常,退出程序 (输入要求每个整数都只能出现一次)。否则,将相应的位置1;第三, 检查每个位,如果某个位是1,就写出相应的整数,从而创建已排序的输出文件。
如果主存容量严苛地限制在1MB,而使用Bitmap需要1.25MB, 因此无法一次载入完成排序。那么,我们可以将该文件分割成两个文件, 再分别用Bitmap处理。分割策略可以简单地把前一半的数据放到一个文件, 后一半的数据放到另一个文件,分别排序后再做归并。 也可以把文件中小于某个数(比如5,000,000)的整数放到一个文件,叫less.txt, 把其余的整数放到另一个文件,叫greater.txt。分别排序后, 把greater.txt的排序结果追加到less.txt的排序结果即可。
关于Bitmap
我们先来看一个具体的例子,假设我们要对0-7内的5个元素(4,7,2,5,3)排序(这里假设这些元素没有重复)。那么我们就可以采用Bit-map的方法来达到排序的目的。要表示8个数,我们就只需要8个Bit(1Bytes),首先我们开辟1Byte的空间,将这些空间的所有Bit位都置为0,如下图:
然后遍历这5个元素,首先第一个元素是4,那么就把4对应的位置为1(可以这样操作 p+(i/8)|(0x01<<(i%8)) 当然了这里的操作涉及到Big-ending和Little-ending的情况,这里默认为Big-ending),因为是从零开始的,所以要把第五位置为一(如下图):
然后再处理第二个元素7,将第八位置为1,,接着再处理第三个元素,一直到最后处理完所有的元素,将相应的位置为1,这时候的内存的Bit位的状态如下:
然后我们现在遍历一遍Bit区域,将该位是一的位的编号输出(2,3,4,5,7),这样就达到了排序的目的。
优点:
1.运算效率高,不用进行比较和移位;
2.占用内存少,比如N=10000000;只需占用内存为N/8=1250000Byte=1.25M。
缺点:
所有的数据不能重复。即不可对重复的数据进行排序和查找。
第二章
- 给定一个包含32位整数的顺序文件,它至多只能包含40亿个这样的整数, 并且整数的次序是随机的。请查找一个此文件中不存在的32位整数。
在有足够主存的情况下,你会如何解决这个问题? 如果你可以使用若干外部临时文件,但可用主存却只有上百字节, 你会如何解决这个问题?
40 * 10^8 * 4B = 16GB (大约值,因为不是按照2的幂来做单位换算)
这个明显无法一次性装入内存中。但是,如果我们用计算机中的一位来表示某个数出现与否, 就可以减少内存使用量。比如在一块连续的内存区域,15出现,则将第15位置1。 这个就是Bit Map算法。关于这个算法,网上有篇文章写得挺通俗易懂的,推荐:
http://blog.csdn.net/hguisu/article/details/7880288
如果用Bit Map算法,一个整数用一位表示出现与否,需要的内存大小是:
40 * 10^8 b = 5 * 10^8 B = 0.5GB
而我们有1GB的内存,因此该算法可行。由于Bit Map只能处理非负数, (没有说在第-1位上置1的),因此对于有符号整数,可以将所有的数加上0x7FFFFFFF, 将数据移动到正半轴,找到一个满足条件的数再减去0x7FFFFFFF即可。 因此本文只考虑无符号整数,对于有负数的情况,按照上面的方法处理即可。
我们遍历一遍文件,将出现的数对应的那一位置1,然后遍历这些位, 找到第一个有0的位即可,这一位对应的数没有出现。
第二问,如果我们只有10MB的内存,明显使用Bit Map也无济于事了。 内存这么小,注定只能分块处理。我们可以将这么多的数据分成许多块, 比如每一个块的大小是1000,那么第一块保存的就是0到999的数,第2块保存的就是1000 到1999的数……实际上我们并不保存这些数,而是给每一个块设置一个计数器。 这样每读入一个数,我们就在它所在的块对应的计数器加1。处理结束之后, 我们找到一个块,它的计数器值小于块大小(1000), 说明了这一段里面一定有数字是文件中所不包含的。然后我们单独处理这个块即可。
接下来我们就可以用Bit Map算法了。我们再遍历一遍数据, 把落在这个块的数对应的位置1(我们要先把这个数归约到0到blocksize之间)。 最后我们找到这个块中第一个为0的位,其对应的数就是一个没有出现在该文件中的数。
- 请将一个具有n个元素的一维向量向左旋转i个位置。例如,假设n=8,i=3, 那么向量abcdefgh旋转之后得到向量defghabc。
这个问题很常见了,做3次翻转即可,无需额外空间:
reverse(0, i-1); // cbadefgh
reverse(i, n-1); // cbahgfed
reverse(0, n-1); // defghabc
- 给定一本英语单词词典,请找出所有的变位词集。例如,因为“pots”, “stop”,“tops”相互之间都是由另一个词的各个字母改变序列而构成的, 因此这些词相互之间就是变位词。
把所有的词排序。遍历每个字符串,把每个字符串排序作为key,key相同的说明互为变位词。
查看map中是否有key,没有key放进去,有key的话就把没排序的s放入key对应的list中。最后输出map.values()
第三章 数据决定程序结构
恰当的数据视图实际上决定了程序的结构。 我们常常可以通过重新组织内部数据来使程序变得小而美。
发明家悖论:更一般性的问题也许更容易解决。(有时候吧)
程序员在节省空间方面无计可施时,将自己从代码中解脱出来, 退回起点并集中心力研究数据,常常能有奇效。数据的表示形式是程序设计的根本。
下面是退回起点进行思考时的几条原则:
-
使用数组重新编写重复代码。冗长的相似代码常常可以使用最简单的数据结构—— 数组来更好地表述。
-
封装复杂结构。当需要非常复杂的数据结构时,使用抽象术语进行定义, 并将操作表示为类。
-
尽可能使用高级工具。超文本,名字-值对,电子表格,数据库, 编程语言等都是特定问题领域中的强大的工具。
-
从数据得出程序的结构。在动手编写代码之前,优秀的程序员会彻底理解输入, 输出和中间数据结构,并围绕这些结构创建程序。
提到的书籍:Polya的《How to Solve it》,中文书《怎样解题》; Kernighan和Plauger的《Elements of Programming Style》;Fred Brooks的《人月神话》 Steve McConnell的《代码大全》;《Rapid Development》; 《Software Project Survival Guide》
编写正确的程序
本章以二分搜索为例子,讲述了如何对程序进行验证及正确性分析。
深入阅读:David Gries的《Science of Programming》 是程序验证领域里极佳的一本入门书籍。
编程中的次要问题
到目前为止,你已经做了一切该做的事:通过深入挖掘定义了正确的问题, 通过仔细选择算法和数据结构平衡了真正的需求,通过程序验证技术写出了优雅的代码, 并且对其正确性相当有把握。万事俱备,只欠编程。
- 使用断言assert
- 自动化测试程序
进阶阅读:《Practice of Programming》第5章(调试),第6章(测试) 《Code Complete》第25章(单元测试),第26章(调试)
程序性能分析
下图展示了一个程序的性能提升过程, 该程序的作用是对三维空间中n个物体的运动进行仿真。从图中可以看出, 一个程序可以从多方面进行性能提升,而其中算法和数据结构的选择又显得尤为重要。
从设计层面提升程序性能:
- 问题定义。良好的问题定义可以有效减少程序运行时间和程序长度。
- 系统结构。将大型系统分解成模块,也许是决定其性能的最重要的单个因素。
- 算法和数据结构。这个不用说了。
- 代码调优。针对代码本身的改进。
- 系统软件。有时候改变系统所基于的软件比改变系统本身更容易。
- 硬件。更快的硬件可以提高系统的性能。
深入阅读:Butler Lampson的“Hints for Computer System Design”, 该论文特别适合于集成硬件和软件的计算机系统设计。
粗略估算
这一章讲述了估算技术,我认为是相当有用的一章。
文中先抛出一个问题:密西西比河一天流出多少水?如果让你来回答, 你会怎么答,注意不能去Google哦。
作者是这么回答这个问题:假设河的出口大约有1英里宽和20英尺深(1/250英里), 而河水的流速是每小时5英里,也就是每天120英里。则可以计算出一天的流量:
1英里 * 1/250英里 * 120英里/天 约等于 1/2 英里^3/天
上述算式非常简单,可是在看到这些文字之前,如果有人真的问你, 密西西比河一天流出多少水?你真的能答上来吗?还是愣了一下后,摆摆手,说: 这我哪知道!
对于上面的问题,我们至少可以注意到以下两点:
- 你需要把问题转换成一个可计算的具体模型。这一点往往不需要太担心, 因为我们做的是估算,所以可以忽视很多无关紧要的因素,可以去简化你的模型, 记住我们要的只是一个粗略计算的结果。比如对于上面的问题, 计算密西西比河一天流出多少水其实就是计算其一天的流量,利用中学所学知识, 流量 = 截面积 x 流速,那我们就只需计算密西西比河的出水口的截面积和流速即可。 我们可以将出水口简化成一个矩形,因此就只需要知道出水口的宽和深即可。
- 你需要知道常识性的东西。上面我们已经把问题转换成了一个可计算的具体模型: 流量 = 出水口宽 x 出水口深 x 流速。接下来呢?你需要代入具体的数值去求得答案。 而这就需要你具备一些常识性的知识了。比如作者就估计了密西西比河的出口有1英里宽, 20英尺深(如果你估计只有几十米宽,那就相差得太离谱了)。 这些常识性的知识比第1点更值得关注,因为你无法给出一个靠谱的估算值往往是因为这点。
当我们懂得如何把一个问题具体化定义出来并为其选用适当的模型, 并且我们也积累了必要的常识性的知识后,回答那些初看起来无从下手的问题也就不难了。 这就是估算的力量。
以下是估算时的一些有用提示:
- 两个答案比一个答案好。即鼓励你从多个角度去对一个问题进行估算, 如果从不同角度得到的答案差别都不大,说明这个估算值是比较靠谱的。
- 快速检验。即量纲检验。即等式两边最终的量纲要一致。 这一点在等式简单的时候相当显而易见。比如位移的单位是米,时间单位是秒, 速度单位是米/秒,那显然我们应该要用位移去除以时间来得到速度, 这样才能保证它们单位的一致。你可能会说,我了个去,这种小学生都懂的事, 你好意思拿出来讲。其实不然,当你面对的是一个具有多个变量的复杂物理公式, 或者你提出某种物理假设,正在考虑将其公式化,该方法可以切切实实地帮你做出检验。
- 经验法则。“72法则”:1.假设以年利率r%投资一笔钱y年,如果r*y = 72, 那么你的投资差不多会翻倍。2.如果一个盘子里的菌群以每小时3%的速率增长, 那么其数量每天(24小时)都会翻倍。在误差不超过千分之五的情况下, \pi秒就是一个纳世纪。也就是说:
3.14秒 = 10^(-9) * 100年 = 10^(-7) 年
也就是说,1年大概是3.14x10^7 秒。所以如果有人告诉你,一个程序运行10^7 秒, 你应该能很快反应出,他说的其实是4个月。
- 实践。与许多其他活动一样,估算技巧只能通过实践来提高。
如果问题的规模太大,我们还可以通过求解它的小规模同质问题来做估算。比如, 我们想测试某个程序运行10亿次需要多长时间,如果你真去跑10亿次, 说不定运行几个小时都没结束,那不是很悲剧?我们可以运行这个程序1万次或是10万次, 得出结果然后倍增它即可。当然,这个结果未必是准确的, 因为你没法保证运行时间是随着运行次数线性增加的。谨慎起见,我们可以运行不同的次数, 来观察它的变化趋势。比如运行10次,100次,1000次,10000次等, 观察它的运行时间是否是线性增加的,或是一条二次曲线。
有时候,我们需要为估算的结果乘上一个安全系数。比如, 我们预估完成某项功能需要时间t,那根据以往经验,也许我们需要为这个值乘上2或4, 这样也许才是一个靠谱的预估值。
Little定律:系统中物体的平均数量等于物体离开系统的平均速率和每个物体在系统中停留 的平均时间的乘积。(如果物体离开和进入系统的总体出入流是平衡的, 那么离开速率也就是进入速率)
举个例子,比如你正在排除等待进入一个火爆的夜总会, 你可以通过估计人们进入的速率来了解自己还要等待多长时间。根据Little定律, 你可以推论:这个地方可以容纳约60人,每个人在里面逗留时间大约是3小时, 因此我们进入夜总会的速率大概是每小时20人。现在队伍中我们前面还有20人, 也就意味着我们还要等待大约一个小时。
深入阅读:Darrell Huff的《How To Lie With Statistics》;关键词: 费米近似(Fermi estimate, Fermi problem)
算法设计技术
这一章就一个小问题研究了4种不同的算法,重点强调这些算法的设计技术。 研究的这个小问题是一个非常常见的面试题:子数组之和的最大值。 如果之前没有听过,建议Google之。
深入阅读:Aho,Hopcroft和Ullman的《Data Structures and Algorithms》 Cormen,Leiserson,Rivest和Stein的《Introduction to Algorithms》
代码调优
前面各章讨论了提高程序效率的高层次方法:问题定义,系统结构, 算法设计及数据结构选择。本章讨论的则是低层次的方法:代码调优。
代码调优的最重要原理就是尽量少用它。不成熟的优化是大量编程灾害的根源。 它会危及程序的正确性,功能性以及可维护性。当效率很重要时, 第一步就是对系统进行性能监视,以确定其运行时间的分布状况。 效率问题可以由多种方法来解决,只有在确信没有更好的解决方案时才考虑进行代码调优。
事实上,如果不是十分十分必要,不要去做代码调优, 因为它会牺牲掉软件的其他许多性质。
节省空间
本章讲述了节省空间的一些重要方法。
减少程序所需数据的存储空间,一般有以下方法:
- 不存储,重新计算。
- 稀疏数据结构。下面着重讲一下这点。
- 数据压缩。可以通过压缩的方式对对象进行编码,以减少存储空间。
- 分配策略。只有在需要的时候才进行分配。
- 垃圾回收。对废弃的存储空间进行回收再利用。
以下是节省代码空间的几种通用技术:
- 函数定义。用函数替换代码中的常见模式可以简化程序,同时减少代码的空间需求。
- 解释程序。用解释程序命令替换长的程序文本。
- 翻译成机器语言。可以将大型系统中的关键部分用汇编语言进行手工编码。
稀疏数据结构
假设我们有一个200 x 200的矩阵(共40000个元素),里面只有2000个元素有值, 其它的都为0,示意图如下:
显然这是一个稀疏矩阵,直接用一个200 x 200 的二维数组来存储这些数据会造成大量的空间浪费,共需要200x200x4B=160KB。 所以,我们应该想办法用另一种形式来存储这些数据。
方法一
使用数组表示所有的列,同时使用链表来表示给定列中的活跃元素。 如下图所示:
该结构中,有200个指针(colhead)和2000条记录(每条记录是两个整数和一个指针), 占用空间是200x4B + 2000x12B = 24800B = 24.8KB, 比直接用二维数组存储(160KB)要小很多。
方法二
我们可以开三个数组来保存这些数,如下图所示:
firstincol是一个长度为201的数组,对于第i列,在数组row中, 下标为firstincol[i]到firstincol[i+1]-1对应的行元素非0, 其值存储在相应的pointnum数组中。
比如对于上图,在第0列中,元素值非0的行有3行,分别是row[0],row[1],row[2], 元素值是pointnum[0],pointnum[1],pointnum[2];在第1列中,元素值非0的行有2行, 分别是row[3],row[4],元素值是pointnum[3],pointnum[4]。依次类推。
该结构所需要的存储空间为2x2000x4B + 201x4B = 16804B = 16.8KB。 由于row数组中的元素全部都小于200,所以每个元素可以用一个unsigned char来保存, firstincol数组中元素最大也就2000,所以可以用一个short(或unsigned short)来保存, pointnum中的元素是一个4B的int, 最终所需空间变为:2000x4B + 2000x1B + 201x2B = 10402B = 10.4KB。
深入阅读:Fred Brooks的《人月神话》
排序
本章先简单介绍了插入排序,然后着重讲述快速排序。
快排