《编程珠玑》第一章读书笔记

设计者确定其设计达到完美的标准不是不能再增加任何东西,而是不能再减少任何东西。

简单来说,《编程珠玑》第一章通过一个硬盘数据排序的问题,引出了一套代码调优的原则。

问题描述

输入:一个包含1000万个不重复的7位10进制整数的本地文件。

输出:按升序排列的输入。

约束:内存空间仅1MB,磁盘存储空间较多,运行时间需要优化到10s左右。

程序设计

基本思想

一个7位10进制整数如果用 \(7\) bytes来表示,1MB(\(2^{20}\) byte)可以保存143000个数字。

一个7位10进制整数如果用 \(4\) bytes来表示,1MB(\(2^{20}\) byte)可以保存 250000个数字。

1000万个数字,需要分40次多趟排序(第一趟挑选出所有数字中0-249999的数字进行排序,第二趟挑选出所有数字中250000-499999的数字进行排序,以此类推。)

改进

用一个10000000位的字符串表示,当且仅当整数 \(i\) 在文件中存在时,第 \(i\) 位为1。按照大小顺序将这个字符串依次输出就行,整个代码分为3个部分。

1、初始化一个1000万位的所有位置都为0的字符串,从而将集合初始化为空。

2、通过读入输入文件中的每一个整数来建立集合,将每个位置置为1。

3、从第0位开始检验1000万位的字符串,如果该位为1,就输出对应整数,由此产生有序的输出文件。

原理

1、明确问题(对小问题的仔细分析可以得到长远的好处

2、使用位图数据结构(有限定义域内的稠密集合)

3、多趟算法(每趟读入一部分数据)

4、时间-空间折中与双赢

5、简单设计(设计者确定其设计达到完美的标准不是不能再增加任何东西,而是不能再减少任何东西。

习题

1、 如果不缺内存,如何使用一个具有库的语言来实现一种排序算法以表示和排序集合?

下面这个 C++ 程序使用 C++ 标准模板库中的 set 容器完成相同的任务。

1
2
3
4
5
6
7
8
9
10
int main(void){
set<int> S;
int i;
set<int>::iterator j;
while (cin >> j)
S.insert(i);
for(j = S.begin(); j != S.end(); ++j)
count << *j << "\n";
return 0;
}

2、如何使用位逻辑运算(如与、或、移位)来实现位向量?

下面的函数(C++)使用常量来设置、清除以及测试位值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define BITSPERWORD 32
#define SHIFT 5
#define MASK 0x1F
#define N 10000000
int a[1 + N / BITSPERWORD];

void set(int i) {
a[i >> SHIFT] |= (1 << (i & MASK));
}
void clr(int i) {
a[i >> SHIFT] &= ~(1 << (i & MASK));
}
int test(int i) {
return a[i >> SHIFT] & (1 << (i & MASK));
}

下面的函数(Python)使用常量来设置、清除以及测试位值:

1
2
3
4
5
6
hash_mask = 8  # 1000 
data_length = 4 # 4个数值
def set(index): # 将第i位(从0开始)从0设置成1或者从1设置成0
hash_mask ^= 1 << (data_length - 1 - index)
def test(index):
return (hash_mask >> index) & 1 == 1 # 如果第i位(从0开始)是1则返回True否则返回False

3、运行时效率是设计目标的一个重要组成部分,所得到的程序需要足够高效。在你自己的系统上实现位图排序并度量其运行时间。该时间与系统排序的运行时间以及习题 1 中排序(调用库实现)的运行时间相比如何?假设 n 为 10 000 000,且输入文件包含 1 000 000个整数,。

下面的 C 代码使用答案 2 中定义的函数来实现排序算法。

1
2
3
4
5
6
7
8
9
10
11
int main(void) {
int i;
for (i = 0; i < N; i++)
clr(i);
while (scanf("%d", $i) != EOF)
set(i);
for (i = 0; i < N; i++)
if (test(i))
printf("%d\n", i);
return 0;
}

使用答案 4 中的程序生成了一个包含 100 万个不同正整数的文件,其中每个正整数都小于 1 000 万。下表列出了使用系统命令行排序、答案 1 中的 C++ 和 C 程序以及位图代码对这些整数进行排序的开销:

系统排序 系统排序 C++/STL C/qsort C/位图
总时间开销(秒) 89 38 12.6 10.7
计算时间开销(秒) 79 28 2.4 0.5
空间开销(MB) 0.8 70 4 1.25

第一行是总时间。 第二行减去了读写文件所需的 10.2 秒输入/输出时间。 尽管通用 C++ 程序所需的内存和 CPU 时间是专用 C 程序的 50 倍,但是代码量仅有专用 C 程序的一半,而且扩展到其他问题也容易得多。

4、如果认真考虑了习题 3,你将会面对生成小于 n 且没有重复的 k 个整数的问题。最简单的方法就是使用前 k 个正整数。这个极端的数据集合将不会明显地改变位图方法的运行时间(将不会明显的显示出不同方法的性能差距),但是可能会歪曲系统排序的运行时间。如何生成位于 0 至 n-1 之间的 k 个不同的随机顺序的随机整数?尽量使你的程序简短且高效。

见第 12 章,尤其是习题 12.8。下面的代码假定 randint(l, u) 返回 l..u 中的一个随机整数。

1
2
3
4
5
6
/* 伪代码 */
for i = [0, n)
x[i] = i
for i = [0, k)
swap(i, randint(i, n - 1))
print x[i]

其中 swap 函数的作用是交换 x 中的两个元素。有关 randint 函数的详细讨论见 12.1 节。

5、那个程序员说他有 1 MB 的可用存储空间,但是我们概要描述的代码需要 1.25 MB 的空间。他可以不费力气地索取到额外的空间。如果 1 MB 空间是严格的边界,你会推荐如何处理呢?你的算法的运行时间又是多少?

使用位图表示 1 000 万个数需要 1000 万个位,或者说 125 万字节。 考虑到没有以数字 0 或 1 打头的电话号码,我们可以将内存需求降低为 100 万字节。 另一种做法是采用两趟算法,首先使用 5000 000 / 8 = 625 000 个字节的存储空间来排序 0 ~ 4 999 999 之间的整数,然后在第二趟排序 5 000 000 ~ 9 999 999 的整数。\(k\) 趟算法可以在 \(k \times n\) 的时间开销和 \(n / k\) 的空间开销内完成对最多 \(n\) 个小于 \(n\) 的无重复正整数的排序。

6、如果那个程序员说的不是每个整数最多出现一次,而是每个整数最多出现 10 次,你又如何建议他呢?你的解决方案如何随着可用存储空间总量的变化而变化?

如果每个整数最多出现 10 次,那么我们就可以使用 4 位的 \(\frac{1}{2}\) 字节来统计它出现的次数。利用习题 5 的答案,我们可以使用 \(10 ,000 ,000 \times \frac{1}{2} = 5, 000, 000\)个字节(每一个数字使用 \(\frac{1}{2}\) 字节进行保存)在 1 趟内完成对整个文件的排序,或使用 $10 ,000 ,000 $个字节在 \(k\) 趟内完成对整个文件的排序。

7 、[R. Weil] 本书 1.4 节中描述的程序存在一些缺陷。首先是假定在输入中没有出现两次的整数。如果某个数出现超过一次的话,会发生什么?在这种情况下,如何修改程序来调用错误处理函数?当输入整数小于零或大于等于 n 时,又会发生什么?如果某个输入不是数值又如何?在这些情况下,程序该如何处理?程序还应该包含哪些明智的检查?描述一些用以测试程序的小型数据集合,并说明如何正确处理上述以及其他的不良情况。

如果某个数出现超过一次的话,会发生什么?重复出现的数字会被覆盖。 在这种情况下,如何修改程序来调用错误处理函数?使用额外的存储空间,记录重复出现数字的次序,输出时按住这个值进行输出。 在这些情况下,程序该如何处理?程序还应该包含哪些明智的检查?如果不允许重复,就在不做处理,读入数据时直接覆盖。 描述一些用以测试程序的小型数据集合,并说明如何正确处理上述以及其他的不良情况。

8、当那个程序员解决该问题的时候,美国所有免费电话的区号都是 800 。现在免费电话的区号包括 800、877 和 888,而且还在增多。如何在 1 MB 空间内完成对所有这些免费电话号码的排序?如何将免费电话号码存储在一个集合中,要求可以实现非常快速的查找以判定一个给定的免费电话号码是否可用或者已经存在?

这种特殊的数字而且数量有限,直接使用关键字索引就行。

9、使用更多的空间来换取更少的运行时间存在一个问题:初始化空间本身需要消耗大量的时间。说明如何设计一种技术,在第一次访问向量的项时将其初始化为 0。你的方案应该使用常量时间进行初始化和向量访问,使用的额外空间应正比于向量的大小。因为该方法通过进一步增加空间来减少初始化的时间,所以仅在空间很廉价、时间很宝贵且向量很稀疏的情况下才考虑使用。

借助于两个额外的 n 元向量 fromto 和一个整数 top,我们就可以使用标识来初始化向量 data[0..n-1]。 如果元素 data[i] 已初始化(值为0),那么 from[i] < top 并且 to[from[i]] = i。因此,from 是一个简单的标识,totop 一起确保了 from 中不会被写入内存里的随机内容。变量 top 初始为 0,下面的代码实现对数组元素 i 的首次访问:

1
2
3
4
from[i] = top
to[top] = i
data[i] = 0
top++

10、在成本低廉的隔日送达时代之前,商店允许顾客通过电话订购商品,并在几天后上门自取。商店的数据库使用客户的电话号码作为其检索的主关键字(客户知道他们自己的电话号码,而且这些关键字几乎都是唯一的)。你如何组织商店的数据库,以允许高效的插入和检索操作?

商店将纸质订单表格放在 10 * 10 的箱数组中,使用客户电话号码的最后两位作为散列索引。当客户打电话下订单时,将订单放到适当的箱中。当客户来取商品时,销售人员顺序搜索对应箱中的订单——这就是经典的“用顺序搜索来解决冲突的开放散列”。电话号码的最后两位数字非常接近于随机,因此是非常理想的散列函数,而最前面的两位数字则很不理想——为什么?一些市政机关使用类似的方案在记事本中记录信息。

11、在 20 世纪 80 年代早期,洛克希德公司加利福尼亚州桑尼维尔市工厂的工程师们每天都要将许多由计算机辅助设计(CAD)系统生成的图纸从工厂送到位于圣克鲁斯市的测试站。虽然仅有 40 公里远,但使用汽车快递服务每天都需要一个多小时的时间(由于交通阻塞和山路崎岖),花费 100 美元。请给出新的数据传输方案并估计每一种方案的费用。

两地的计算机原先是通过微波连接的,但是当时测试站打印图纸所需的打印机却非常昂贵。因此,该团队在主厂绘制图纸,然后拍摄下来并通过信鸽把 35 毫米的底片送到测试站,在测试站进行放大并打印成图片。鸽子来回一次需要 45 分钟,是汽车所需时间的一半,并且每天只需要花费几美元。在项目开发的 16 个月中,信鸽传送了几百卷底片,仅丢失了两卷(当地有鹰,因此没有让信鸽传送机密数据)。由于现在打印机比较便宜,因此可以使用微波链路解决该问题。

12、载人航天的先驱们很快就意识到需要在外太空的极端环境下实现顺利书写。民间盛传美国国家宇航局(NASA)花费 100 万美元研发出了一种特殊的钢笔来解决这个问题。那么,前苏联又会如何解决相同的问题呢?

根据该传闻,前苏联人用铅笔解决了这个问题。有关这个真实故事的背景请查看www.spacepen.com(刚看了下,价格还可以接受)。Fisher Space Pen 公司成立于 1948 年,其书写设备被俄国航天局、水底探测人员和喜马拉雅登山探险队使用过。

参考连接

https://github.com/Folgerjun/Programming-Pearls/blob/master/Chapter-One.md


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!