Skip to content

Latest commit

 

History

History
124 lines (89 loc) · 11.1 KB

Chapter-Two.md

File metadata and controls

124 lines (89 loc) · 11.1 KB

第二章 啊哈!算法

A. 给定一个最多包含 40 亿个随机排列的 32 位整数的顺序文件,找出一个不在文件中的 32 位整数(在文件中至少缺失一个这样的数——为什么?)。在具有足够内存的情况下,如何解决该问题?如果有几个外部的“临时”文件可用,但是仅有几百字节的内存,又该如何解决该问题?

我们从表示每个整数的 32 位的视角来考虑二分搜索。算法的第一趟(最多)读取 40 亿个输入整数,并把起始位为 0 的整数写入一个顺序文件,把起始位为 1 的整数写入另一个顺序文件。

这两个文件中,有一个文件最多包含 20 亿个整数,我们接下来将该文件用作当前输入并重复探测过程,但这次探测的是第二个位。如果原始的输入文件包含 n 个元素,那么第一趟将读取 n 个整数,第二趟最多读取 n/2 个整数,第三趟最多读取 n/4 个整数,依此类推,所以总的运行时间正比于 n。通过排序文件并扫描,我们也能够找到缺失的整数,但是这样做会导致运行时间正比于 n log n。本习题是伊利诺伊大学的 Ed Reingold 给出的一道测验题。

B. 将一个 n 元一维向量向左旋转 i 个位置。例如,当 n=8 且 i=3 时,向量abcdefgh 旋转为 defghabc。简单的代码使用一个 n 元的中间向量在 n 步内完成该工作。你能否仅使用数十个额外字节的存储空间,在正比于 n 的时间内完成向量的旋转?

杂技算法 翻转算法

Java JDK Collections源码中有类似代码实现 rotate,也可详见习题三解答

C. 给定一个英语字典,找出其中的所有变位词集合。例如,“pots”、“stop”和“tops”互为变位词,因为每一个单词都可以通过改变其他单词中字母的顺序来得到。

标识字典中的每一个词,使得在相同变位词类中的单词具有相同的标识。然后,将所有具有相同标识的单词集中在一起。这将原始的变位词问题简化为两个子问题:选择标识和集中具有相同标识的单词。

1. 考虑查找给定输入单词的所有变位词问题。仅给定单词和字典的情况下,如何解决该问题?如果有一些时间和空间可以在响应任何查询之前预先处理字典,又会如何?

为了找出给定单词的所有变位词,我们首先计算它的标识。如果不允许进行预处理,那么我们只能顺序读取整个字典,计算每个单词的标识并比较两个标识。如果允许进行预处理,我们可以在一个预先计算好的结构中执行二分搜索,该结构中包含按标识排序的(标识,单词)对。Musser 和 Saini 在他们的 STL Tutorial and Reference Guide(Addison-Wesley 出版社 1996 年出版)一书的第 12 章~第 15 章实现了几个变位词程序。

2. 给定包含 4 300 000 000 个 32 位整数的顺序文件,如何找出一个至少出现两次的整数?

二分搜索通过递归搜索包含半数以上整数的子区间来查找至少出现两次的单词。我最初的解决方案不能保证每次迭代都将整数数目减半,所以 log2 n 趟的最坏情况运行时间正比于 n log n。Jim Saxe经过观察发现,该搜索用不着考虑过多的重复元素,从而可以把运行时间缩短为线性时间。如果他的搜索程序知道当前范围内的 m 个整数中一定有重复元素,那么程序只会在当前工作磁带上存储 m+1 个整数,此后过来的整数将会被丢弃。虽然他的方法经常会忽略输入变量,但其策略却足以确保至少能找到一个重复元素。

3. 前面涉及了两个需要精巧代码来实现的向量旋转算法。将其分别作为独立的程序实现。在每个程序中,i 和 n 的最大公约数如何出现?

下面的“杂技”代码将 x[n] 向左旋转 rotdist 个位置。

for i = [0, gcd(rotdist, n))
    /* move i-th values of blocks */
    t = x[i]
    j = i
    loop
        k = j + rotdist
        if k >= n
            k -= n
        if k == i
            break
        x[j] == x[k]
        j = k
    x[j] = t

rotdist 和 n 的最大公约数是所需的置换次数(用近世代数术语来说,也就是旋转产生的置换群的陪集个数)。

下一个程序来自 Gries 的 Science of Programming 一书的 18.1 节,它假设函数 swap(a, b, m)的功能是交换x[a..a+m-1] 和 x[b..b+m-1].

if rotdist == 0 || rotdist == n
    return
i = p = rotdist
j = n - p
while i != j
    /* invariant:
        x[0 .. p-i] in final position
        x[p-i .. p-1] = a(to be swapped with b)
        x[p .. p+j-1] = b(to be swapped with a)
        x[p+j .. n-1] = in final position
    */
    if i > j
        swap(p-i, p, j)
        i -= j
    else
        swap(p-i, p+j-i, i)
        j -= i
swap(p-i, p, i)

有关循环不变式的描述见第 4 章。

该代码跟下面这段(虽然慢但是正确的)计算 i 和 j 的最大公约数的欧几里得算法是同构的(代码假设输入都不为零)。

int gcd(int i, int j)
    while i != j
        if i > j
            i -= j
        else
            j -= i
    return i

Gries 和 Mills 在康奈尔大学计算机科学技术报告 81-452 的“交换部分”研究了所有三种旋转算法。

4. 几位读者指出,既然所有的三个旋转算法需要的运行时间都正比于 n,杂技算法的运行速度显然是求逆算法的两倍。杂技算法对数组中的每个元素仅存储和读取一次,而求逆算法需要两次。在实际的计算机上进行实验以比较两者的速度差异,特别注意内存引用位置附近的问题。

我在 400 MHz 的 Pentium II 机器上运行了所有三种算法,运行时把 n 固定为 1 000 000,并使旋转距离从 1 变化到 50。下图绘制了在每个数据集上 50 次运行的平均时间:

不好意思,图略

求逆代码的运行时间比较一致,约为每个元素 58 纳秒,仅当旋转距离模 8 余 4 时跳到约 66 纳秒(这可能跟 32 字节的缓存大小有关)。块交换算法开始时开销最高(可能是由交换单元素块的函数调用引起的),但是良好的高速缓存性能使得旋转距离大于 2 时该算法是最快的算法。杂技算法开始时开销最低,但是由于其高速缓存性能很差(从每一个 32 字节的高速缓存线中访问单个元素),当旋转距离为 8 时,所需时间将近 200 纳秒。杂技算法的时间在 190 纳秒左右浮动,偶尔会有所下降(当旋转距离为 1 000 时,它的运行时间会降到 105 纳秒,然后马上又恢复到 190)。20 世纪 80 年代中期,当旋转距离设置为页面大小时,这一代码使得页面的性能不稳定。

5. 向量旋转函数将向量 ab 变为 ba。如何将向量 abc 变为 cba?(这对交换非相邻内存块的问题进行了建模)。

利用恒等式 cba = (a^r b^r c^r)^r

6. 20 世纪 70 年代末期,贝尔实验室开发出了“用户操作的电话号码簿辅助程序”,改程序允许雇员使用标准的按键电话在公司电话号码簿中查找电话号码。

模拟电话按键
1 2 / ABC 3 / DEF
4 / GHI 5 / JKL 6 / MNO
7 / PRS 8 / TUV 9 / WXY
* 0 / OPEN #
**

要查找该系统设计者的名字 Mike Lesk,可以按“LESK\*M\*”(也就是“5375\*6\*”),随后,系统会输出他的电话号码。这样的服务现在随处可见。该系统中出现的一个问题是,不同的名字有可能具有相同的按键编码。在 Lesk 的系统中发生这种情况时,系统会询问用户更多的信息。给定一个大的名字文件(例如标准的大城市电话号码簿),如何定位这些“错误匹配”呢?(当 Lesk 在这种规模的电话号码簿上做实验时,他发现错误匹配发生的概率仅仅是 0.2%。)如何实现一个以名字的按键编码为参数,并返回所有可能的匹配名字的函数?

**

名字的标识是其按键编码,所以“LESK*M*”的标识是“5375*6*”。为了在字典中找出错误的匹配,我们用按键编码标识每个名字,并根据标识排序(当标识相同时根据名字排序),然后顺序读取排序后的文件并输出具有不同名字的相同标识。为了检索出给定按钮编码的名字,我们可以使用一种包含标识和其他数据的结构。尽管我们可以对该结构排序,然后用二分搜索查询按键编码;实际系统往往使用散列技术或数据库系统。

7. 在 20 世纪 60 年代早期,Vic Vyssotsky 与一个程序员一起工作,该程序员需要转置一个存储在磁带上的 4 000 * 4 000 的矩阵(每条记录的格式相同,为数十个字节)。他的同事最初提出的程序需要运行 50 个小时。Vyssotsky 如何将运行时间减少到半个小时呢?

为了转置行矩阵,Vyssotsky 为每条记录插入列号和行号,然后调用系统的磁带排序程序先按列排序再按行排序,最后使用另一个程序删除列号和行号。

8. [J.Ullman]给定一个 n 元实数集合、一个实数 t 和一个整数 k,如何快速确定是否存在一个 k 元子集,其元素之和不超过 t?

该问题的啊哈!灵机一动是:当且仅当包含 k 个最小元素的子集之和不超过 t 时,总和不超过 t 的 k 元子集是存在的。可以通过排序原始集合,在正比于 n log n 的时间内找到该子集;也可以使用选择算法(见习题11.9),在正比于 n 的时间内找到该子集。当 Ullman 将这道题作为课堂作业布置时,学生们不仅设计出了上述运行时间的算法,还设计出了时间复杂度为O(n log k)、O(nk)、O(n^2)和O(n^k)的算法。你能否给出对应于这些运行时间的自然算法?

9. 顺序搜索和二分搜索代表了搜索时间和预处理时间之间的折中。处理一个 n 元表格时,需要执行多少次二分搜索才能弥补对表进行排序所消耗的预处理时间?

s 次顺序搜索的开销正比于 sn,s 次二次搜索的总开销等于搜索的开销加上对表排序所需的时间。在对各种算法的常量因子给予足够的信任之前,请看习题9.9。

10. 某一天,一个新研究员向托马斯·爱迪生报到。爱迪生要求他计算出一个空灯泡壳的容积。在使用测径仪和微积分进行数小时的计算后,这个新员工给出了自己的答案——150cm^3。而爱迪生在几秒钟之内就计算完毕并给出了结果“更接近155”。他是如何实现的呢?

爱迪生在灯泡壳中灌满了水,然后将这些水倒入一个具有刻度的圆柱体中。(如果你注意提示可能就会发现,阿基米德也使用水来计算体积;他在获得啊哈!灵机一动后大喊“我发现了!”来庆祝。)