首页 > 代码库 > 《编程珠玑》---笔记。浏览此文,一窥此书。

《编程珠玑》---笔记。浏览此文,一窥此书。


第一章:


磁盘排序:对于一个提出的问题,不要未经思考就直接给出答案。要先深入研究问题,搞清楚这个问题的特点,根据这个特点,可能有更好的解决方案。

 

比如:文中:最初的需求只是“我如何对磁盘文件排序”。

我们首先想到了经典的归并排序。

但,进一步了解到排序的内容是10000000个记录,每条记录都是一个7位整数,且只有1M可用的内存。每条记录不相同。

 

【位示图法,详见我的关于排序的博文】

 

第二章:

三个问题:

1、给定一个包含32位整数的顺序文件,它至多只能包含40亿个这样的整数,并且整数的次序是随机的。请查找一个此文件中不存在的32位整数。在有足够内存的情况下,你会如何解决这个问题?如果你可以使用若干外部临时文件但可用主存却只有上百字节,你会如何解决这个问题?

 

若内存足够用则可用位图方式。

若内存不够用,可用二分查找的方式。

  

2、将一个具有n个元素的一维向量向左旋转i个位置。例如,假设n=8,i=3,那么向量abcdefgh旋转之后得到向量defghabc。你只能使用1字节的辅助变量。

 

先将向量abcdefgh逆序,得到hgfedcba,再以后i个位置为分割,hgfed和cba分别逆序,得到defghabc

  

3、给定一本英语单词词典,请找出所有的变位词集。例如,因为”pots” “stop” “tops”相互之间都是由另一个词的各个字母改变序列而构成的,因此这些词相互之间就是变位词。

 

将词典中的每个单词都进行签名,这样同一变位词类中的单词会具有相同的签名,然后将具有相同签名的单词归拢到一起。

签名:通过计数表示字母重复次数的方式给出。(例如:”mississippi”的签名可能是”i4m1p2s4”)。

然后以签名为键进行排序,把具有相同签名的单词挤压到一行。

 

第三章:数据结构程序

表单字母编程:

例如:

当你登录到一个购物网站,其会弹出如下页面:


作为一个程序员,你应该意识到计算机从数据库中查询你的姓名并取回了相关数据。

但是程序该如何精确地从你的数据库记录中构建那个定制的Web页面呢?草率的程序员可能很想像下面那样开始编写程序:


更好的方法就是编写一个依赖于下面这样的表单字母模式的表单字母生成器:


表示法$i表示记录中的第i个字段,所以$0表示姓,等等。下面的伪码将解释该模式。

(这段伪码假定字母$字符在输入模式中写为$$)

read field from database
loop from start to end of schema
    c= next character in shema
   if c != '$'
       printchar c
   else
       c = next character in schema
       case c of
           '$':     printchar '$'
           '0'-'9': printstring field[c]
           default: error("bad schema")


与编写明显的程序相比,编写生成器和模式或许更加简单些。将数据从控件中分离开来可以使你大大受益:如果字母重新设计,那么可以在文本编辑器中操作该模式。

 

【我思】

构造更多更好的数据结构,把数据从代码中分离出来。 你的程序会更短小、精悍、易于维护、易于扩展。

面向过程编程:把数据从代码中抽离出来

面向对象编程:把数据从代码中抽离出来且把处理这个数据结构的专门代码和此数据结构 绑定到一起,组成一个类。

 

---

数据结构对软件的一个贡献:将大程序缩减为小程序。数据结构还有其他许多正面的影响,包括时间和空间的缩减。增加可移植性和可维护性。

 

程序员在对空间缺乏无能为力时,往往会脱离代码的纠缠,回过头去凝神考虑他的数据,这样会找到更好的方法。表示法是编程的精华。  ——Fred Brook 《Mythical Man Month》

 

几个原则

1、将重复性代码改写到数组中。

使用最简单的数据结构——数组——来表示一段冗长的相类似的代码往往能达到最佳效果。

(例如各种if 整合到数组中)

2、封装复杂的结构

当你需要一个复杂的数据结构时,使用抽象的术语对它进行定义,并将那些操作表示成一个类。

3、让数据去构造程序。

使用适当的数据结构去替换复杂的代码,这可以使数据起到构造某个程序的效果。

(在编写代码之前,好的程序员通常都会通篇理解构建程序时所围绕的输入数据结构、输出数据结构以及中间数据结构。)

 

 

第四章:编写正确的程序

 

断言在程序维护期间很关键。

 

保持代码的简单性通常是正确性的关键。

 

关于循环不变式:(还可参照《算法导论》插入排序部分)

例如二分查找:

二分查找的关键概念在于我们总是知道如果t在数组x[0…n-1]中的某处,那么它必定在x的某个范围中(我们致力于缩减这个范围)。我们使用简写形式mustbe(range)表示如果t在数组中,那么它必定在range中。

 

初始化range  to 0…..n-1

Loop

         {invariant: must(range) }

         If  range is empty,

      Break  and report  that t  is not in the array

    计算m (range的中间位置)

用m值来缩减range的范围

若在缩减的过程中发现了t目标值,则break,打印出它的位置。

 

本程序的关键部分就是loop invariant,即用{}括起来的部分。有关程序状态的这个断言被称为不变式(invariant),因为每一次循环迭代的开始和结尾它都是真值(true)。

 

l = 0; u = n-1

loop

    {mustbe(l, u) }

   if l > u

       p = -1; break

    m= (l + u)/2

   case

       x[m] < t : l = m+1

       x[m] == t : p = m; break

       x[m] > t : u = m-1

 

程序验证的基本技术是先精确指定该不变式,并在我们编写每一行代码时密切关注以保持该不变式。在我们将算法草图转成伪码时,这种技术对我们帮助极大。

 

(当循环涉及到影响不变式的语句时,要检测此不变式是否更改。)

 

二叉查找的优化:返回目标数第一次出现的位置。

l = -1; u = n

while l+1 != u

   //invariant: x[l] < t && x[u] >= t && l < u

    m= (l + u) / 2

   if x[m] < t

       l = m

   else

       u = m

//assert l+1 = u && x[l] < t&& x[u] >= t

p = u

if p >= n || x[p] != t

    p= -1

 

 

第五章:构建脚手架


当不得不调试一个深深嵌入到一个大型程序中的小算法时,我有时会使用诸如单步调试那样的调试工具来调试该大型程序。但是,当我像上面那样使用脚手架调试一个算法时,使用printf语句通常实现要更快一些,比起复杂调试工具来说也要更加有效一些。

 

我们使用断言来陈述我们相信某个逻辑表达式是正确的。

 

(这一章就是讲怎样写一段小代码来完成自动化测试。 我觉得没什么意思,就略了。)

 

第六章:性能透视


程序的加速是通过在若干不同的层次上努力得到的。

算法和数据结构。

算法优化。

数据结构重组。

代码优化。

硬件。

 

(这一章也没有什么有意义的内容。)

 

第七章:封底计算(学会估算,有点意思~)


七二法则:


假定你投入了一笔钱,时间是y年,利率每年是r% 如果r*y = 72,那么大致说来你投入的钱会翻番的。

[例如:]如果你投资1000美元,时间是12年,利息是6%,那么届时你将得到2012美元;而花9年的时间,利息为8%时,投资1000美元,将得到1999美元。

 

一年有3.155*107 秒。简便的记法是:PI秒是一纳世纪。

 

为补偿我们的无知,在估算规模、成本以及进度时,我们应该保留2或4的系数,以弥补我们在某个方面的缺漏。

 

利特尔法则:


系统中物体的平均数量就是系统中物体离开系统的平均比率和每个物体在系统中所花费的平均时间的乘积。(如果物体进入和离开系统存在一个总体上的流量平衡的话,出去率也就是进入率。)

简单概况:“队列中的平均物体数量是进入率和平均滞留时间之乘积。”

【例如】

我在地下室中存有150个盛酒容器,每年我都要喝完(和买回)25个容器的酒。请问每个容器我要保存多少年?

利特尔法则告诉我们用150个容器除以25个容器/年即得到6年。

 

计算机上的应用:

[可以使用利特尔法则定律和流量平衡证明用于多用户系统的响应时间准则]

假设平均思考时间为z的n个用户连接到了一个任意的系统中,其响应时间为r。每个用户都在思考和等待响应之间循环,所以元系统(由用户和计算机系统组成)中的总计作业数都固定在n上。

这个元系统,其平均负载为n,平均响应时间为z+r,吞吐量为x(按每个时间单位的作业数度量)。利特尔法则表明n = x*(z+r),解析一下得到r = n/x - z

 

当你使用封底计算时,一定要回忆一下爱因斯坦的名言:

任何事都应该做到尽可能的简单,除非没有更简单的了。

 

 

第八章:算法设计技术


问题:

输入:一个具有n个浮点数字的向量x;

输出:是在输入的任何相邻子向量中找出的最大和。

 

O(n2)的解法:

Maxsofar = 0 ;

for i = [ 0, n )   //以i为首的 所有可能序列的sum情况

sum = 0;

for  j = [ i, n )

             sum += x[j]

    maxsofar = max(maxsofar, sum) ; //以i为首的 所有可能序列的sum 的最大值

 

另一个备选的二次算法是通过访问在外部循环执行之前就已构建的数据结构的方式在内部循环中计算总和。(把所有的组合计算的结果存储在表中供以后查询。)

 

cumarr[-1] = 0

for i = [0, n)

   cumarr[i] = cumarr[i-1] + x[i]

maxsofar = 0

for i = [0, n)

   for j = [i, n)

       sum = cumarr[j] - cumarr[i -1]

       maxsofar = max(maxsofar, sum) ;

 

//cumarr的第i个元素包含x[0…i]中的各个值的累加和,所以x[i…j]中各个值的总和可以通过计算cumarr[j] –cumarr[i-1]得到。

 

分治算法

要解决规模为n的问题,可递归解决两个规模近似为n/2的子问题,然后将它们的答案进行合并以得到整个问题的答案。

 

对于此问题,初始问题要处理大小为n的向量,所以将它划分为两个子问题的最自然的方法就是创建两个大小相当的子向量a、b。然后我们递归找出a和b中元素和最大的子向量,我们称之为ma和mb

在整个向量中最大总和的子向量要么整个在a中,要么整个在b中,或跨越a和b之间的边界。我们将跨越边界的最大值称为mc

这样我们的分治算法将递归计算ma或mb,并通过其他的方法计算mc,然后返回三个中最大的那一个。

 

float maxsum3(l, u)

   if (l > u)  //0个元素

       return 0 ;

   if (l == u) //1个元素

       return max(0, x[l]) ;

 

    m= (l + u)/2

   //找穿越左右的左半部分的最大和

   lmax = sum = 0

   for (i = m; i >= l; i--)   //所有以中间m为终点的数链和 最大的

       sum += x[i]

       lmax = max(lmax, sum) ;

 

   //找穿越左右的右半部分的最大和

   rmax = sum = 0

   for i = (m, u]       //所有以中间m为起点的数链和 最大的

       sum += x[i]

       rmax = max(rmax, sum)

 

   return max(lmax+rmax, maxsum3(l, m), maxsum3(m+1, u))

 

扫描算法

它从最左端开始,一直扫描到最右端(x[n-1]),记下所碰到过的最大总和子向量。最大值最初是0。前i个元素中,最大总和子数组要么在i-1个元素中,要么截止到位置i。

 

maxsofar = 0

maxendinghere = 0

for i = [0, n)

   maxendinghere = max(maxendinghere + x[i], 0) 

   maxsofar = max(maxsofar, maxendinghere)     //记录下到i的最大子数组和

 

//在该循环的第一个赋值语句前,maxendinghere包含了截止于位置i-1的最大子向量的值;赋值语句修改它以包含截止于位置i的最大子向量的值。

 


几个重要的算法技术:

保存状态,避免重新计算。 

O(n2)算法和扫描算法,使用了简单的动态编程形式。通过使用一些空间来保存各个结果,我们就可以避免因重新计算而浪费时间。

 

将信息预处理到数据结构中

例如上面O(n2)算法中的cumarr结构允许对子向量中的总和进行快速计算。

 

分治算法

 

扫描算法

有关数组的问题经常可以通过询问“我如何可以将x[0…i-1]的解决方案扩展为x[0..i]的解决方案?”的方式得到解决。

 

累积:

例如上面O(n2)算法使用了累积表,在累积表中,第i个元素包含x中前i个值的总和。

在处理范围时,这一类很常见。

  

第九章:代码优化


绘制程序轮廓,确定每个函数需要花费的时间。

 

研究内存分配程序。调用malloc函数,代价较大。

且调用malloc分配结点,每个结点要多消耗一些字节。

所以我们可以自己申请一段空间,自己管理内存分配。(例如伙伴算法),这样,分配的结点都在这段空间中,有利于利用缓存。且节省空间。

 

 

应用缓存原理

访问最频繁的数据访问起来也应该最便宜。

 

若我们经常调用malloc函数,将最常用类型的空闲记录捕获在一个链表中。以后在处理常见的请求时,就可以快速访问那个链表而不需调用通用的内存分配程序。这可大幅提高效率。

 

·如果某个程序将时间主要花费在输入输出之上,企图加速该程序中的计算将是毫无价值的。在现代的体系结构中,有这么多的周期花在访问内存上时,企图减少计算时间同样是毫无用处的。

 

将循环展开

在现代的机器中将循环展开有助于避免流水线阻塞,减少分支,增加指令级的并行。

 

====

 

第十章:压缩空间


表示稀疏矩阵:

使用一个数组表示所有的列,使用链表表示给定列中的活动元素。

这样,比用二维数组表示节省很多空间。

 

还要注意,即使数组接触的数据比链表要多,它们也要更快,这是因为它们的顺序内存访问和系统高速缓存之间交互作用时效率更高。

 

对于许多跨网络的程序来说,“保存,不进行重新传输”,有时我们可以通过本地缓存方式减少需要传输的数据量。

 

第11章:排序

关于排序,我的博客中讲的更详尽。

 

第12章:抽样问题

不觉得有什么实际用处。略。

 

第13章:查找

如果事先知道集合的大小,那么数组这种结构来比较适合实现集合。因为数组是有序的,所以能用二分查找建立一个运行时间为O(logn)的成员函数。

 

简单的二分查找树避免了STL所使用的复杂的平衡方法,因此,它会稍微快一些,同时使用的空间也更少一些。

最重要的是它一下子就分配所有的结点。(自己管理分配)这就大大降低了树的空间需求,运行时间降低了。

(在二分查找树中使用定制的内存分配,将空间减小了3倍,时间减少了2倍。)

 

 

第14章:堆

关于查找和堆,我的博客中讲的更详尽。

 

 

 【后话】

《编程珠玑》名气很大,但是其内容比较散乱,且有些章节讲述方式比较乏味,我从书中提取了我认为是主要的干货,没时间读此书的朋友可以浏览一下 以看大概。

 (这书的风格不适合我,我个人还是喜欢中规中矩的讲解比较系统的书^_^)