我们从表示每个整数的 32 位的视角来考虑二分搜索。算法的第一趟(最多)读取 40 亿个输入整数,并把起始位为 0 的整数写入一个顺序文件,把起始位为 1 的整数写入另一个顺序文件。
这两个文件中,有一个文件最多包含 20 亿个整数,我们接下来将该文件用作当前输入并重复探测过程,但这次探测的是第二个位。如果原始的输入文件包含 n 个元素,那么第一趟将读取 n 个整数,第二趟最多读取 n/2 个整数,第三趟最多读取 n/4 个整数,依此类推,所以总的运行时间正比于 n。通过排序文件并扫描,我们也能够找到缺失的整数,但是这样做会导致运行时间正比于 n log n。本习题是伊利诺伊大学的 Ed Reingold 给出的一道测验题。
杂技算法 翻转算法
Java JDK Collections源码中有类似代码实现 rotate,
标识字典中的每一个词,使得在相同变位词类中的单词具有相同的标识。然后,将所有具有相同标识的单词集中在一起。这将原始的变位词问题简化为两个子问题:选择标识和集中具有相同标识的单词。
为了找出给定单词的所有变位词,我们首先计算它的标识。如果不允许进行预处理,那么我们只能顺序读取整个字典,计算每个单词的标识并比较两个标识。如果允许进行预处理,我们可以在一个预先计算好的结构中执行二分搜索,该结构中包含按标识排序的(标识,单词)对。Musser 和 Saini 在他们的 STL Tutorial and Reference Guide(Addison-Wesley 出版社 1996 年出版)一书的第 12 章~第 15 章实现了几个变位词程序。
二分搜索通过递归搜索包含半数以上整数的子区间来查找至少出现两次的单词。我最初的解决方案不能保证每次迭代都将整数数目减半,所以 log2 n 趟的最坏情况运行时间正比于 n log n。Jim Saxe经过观察发现,该搜索用不着考虑过多的重复元素,从而可以把运行时间缩短为线性时间。如果他的搜索程序知道当前范围内的 m 个整数中一定有重复元素,那么程序只会在当前工作磁带上存储 m+1 个整数,此后过来的整数将会被丢弃。虽然他的方法经常会忽略输入变量,但其策略却足以确保至少能找到一个重复元素。
下面的“杂技”代码将 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 的“交换部分”研究了所有三种旋转算法。
我在 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 年代中期,当旋转距离设置为页面大小时,这一代码使得页面的性能不稳定。
利用恒等式 cba = (a^r b^r cr)r
模拟电话按键 | ||
---|---|---|
1 | 2 / ABC | 3 / DEF |
4 / GHI | 5 / JKL | 6 / MNO |
7 / PRS | 8 / TUV | 9 / WXY |
* | 0 / OPEN | # |
名字的标识是其按键编码,所以“LESK*M*”的标识是“5375*6*”。为了在字典中找出错误的匹配,我们用按键编码标识每个名字,并根据标识排序(当标识相同时根据名字排序),然后顺序读取排序后的文件并输出具有不同名字的相同标识。为了检索出给定按钮编码的名字,我们可以使用一种包含标识和其他数据的结构。尽管我们可以对该结构排序,然后用二分搜索查询按键编码;实际系统往往使用散列技术或数据库系统。
为了转置行矩阵,Vyssotsky 为每条记录插入列号和行号,然后调用系统的磁带排序程序先按列排序再按行排序,最后使用另一个程序删除列号和行号。
该问题的啊哈!灵机一动是:当且仅当包含 k 个最小元素的子集之和不超过 t 时,总和不超过 t 的 k 元子集是存在的。可以通过排序原始集合,在正比于 n log n 的时间内找到该子集;也可以使用选择算法(见习题11.9),在正比于 n 的时间内找到该子集。当 Ullman 将这道题作为课堂作业布置时,学生们不仅设计出了上述运行时间的算法,还设计出了时间复杂度为O(n log k)、O(nk)、O(n2)和O(nk)的算法。你能否给出对应于这些运行时间的自然算法?
s 次顺序搜索的开销正比于 sn,s 次二次搜索的总开销等于搜索的开销加上对表排序所需的时间。在对各种算法的常量因子给予足够的信任之前,请看习题9.9。
爱迪生在灯泡壳中灌满了水,然后将这些水倒入一个具有刻度的圆柱体中。(如果你注意提示可能就会发现,阿基米德也使用水来计算体积;他在获得啊哈!灵机一动后大喊“我发现了!”来庆祝。)