外部排序¶
约 3432 个字 12 张图片 预计阅读时间 14 分钟
问题引入
大部分内部排序算法都用到内存可直接寻址的事实。但是,当数据量大于内存容量时,我们就需要考虑外部排序。如果输入数据在磁带上,那么所有这些操作就失去了它们的效率,因为磁带上的元素只能被顺序访问(磁头只能顺序移动,如果不顺序访问那就很耗时间)。即使数据在一张磁盘上,由于转动磁盘和移动磁头所需的延迟,仍然存在实际上的效率损失。
基于这一原因,我们在外部排序时适合使用的是归并排序,因为归并排序的时间复杂度来源于归并,而归并只需要顺序扫描两个有序数组,然后写入的时候也是顺序写入,因此适用于外部排序。
简单算法¶
设我们有四盘磁带,\(T_{a1} ,T_{a2} ,T_{b1} ,T_{b2}\),它们两两一组交替作为输入磁带和输出磁带。
设数据最初在 \(T_{a1}\) 上,并设初始数组有 \(N\) 个元素,并且内存可以一次容纳(和排序)\(M\) 个记录(在这里,记录是含有一定量数据的一个单位)。一种自然的做法是
- 第一步从输入磁带 \(T_{a1}\) 一次读入 \(M\) 个记录到内存中,在内存内部将这些记录排序,然后再把这些排过序的记录交替地写到 \(T_{b1}\) 或 \(T_{b2}\) 上。我们将把每组排序好的记录叫做一个顺串(run)。
- 现在 \(T_{a1}\) 上的数据已经都取出来了,将\(T_{b1}\) 和 \(T_{b2}\) 作为输入磁带,把它们两个的第一个顺串取出并按顺序合并(归并),把结果写入到 \(T_{a1}\) 上,这个结果是一个二倍长的顺串。然后再取出各自的下一个顺串合并到 \(T_{a2}\) 上,重复这个过程直到 \(T_{b1}\) 或 \(T_{b2}\) 为空。此时,或者它们均为空,或者剩下一个顺串。对于后者,我们把剩下的顺串拷贝到适当的磁盘末尾即可。
- 我们重复上述步骤,直到最终得到一个长为 \(N\) 的顺串为止。
显然因为每一趟工作后顺串长度都加倍,该算法将需要 \(\lceil \log_2(N/M) \rceil\) 趟工作(这里的趟就是 PPT 上的 pass,表示一轮从输入磁盘把数据合并、运输到输出磁盘的整个工作),外加一趟从磁带 \(T_{a1}\) 开始把完全打乱的数据运输到 \(T_{b1}\) 或 \(T_{b2}\) 上构造初始的顺串的过程。
例如上面 PPT 上的例子,第一趟构造出长度为 3 的顺串,接下来是三趟工作:顺串长度依次由 3 变 6,6 变 12,12 变 13,因此共 4 趟操作。
Example
若我们有 1000 万个记录需要排序,每个记录是 128 个字节,并且我们的内存容量为 4MB,那么我们需要多少趟(pass)操作?
解答:由于 4MB = \(2^{22}\) bytes,因此内存中一次可以读取 \(2^{22} / 128 = 2^{17}\) 个记录,因此一个顺串的大小也就为 \(2^{17}\) 个记录。于是第一步我们会产生 $ 10^7 / 2^{17} = 305.17 \approx 306$ 个顺串,因此我们总共需要 \(1+ \lceil log_2(306) \rceil = 1+9 = 10\) 趟操作。
PPT 中认为第一步会产生 320 个顺串,这是因为它把 4MB 当作 \(4 \times 10^6\) bytes 了,虽然这并不会影响最终的结果。
从上面这个简单算法中我们可以找到一些优化的方向
- 减少工作的趟数
- 减少合并的次数
- 使用缓冲区来实现并行的操作
- 优化顺串的构造
多路合并¶
第一个优化方向就是减少工作的趟数,显然如果我们把 2 路归并转换为 \(k\) 路的归并,也就是每次归并 \(k\) 条纸带上对应位置的顺串。那么每次合并后顺串长度增加 \(k\) 倍,因此加上初始的 1 趟,我们只需要 \(1 + \lceil \log_k(N/M) \rceil\) 趟即可完成排序,减少了趟数,因此减少了磁带移动的次数,从而减少总时间。这样的算法称为 \(k\) 路合并。
\(k\) 路合并有一个实现上需要注意的点,因为我们是 \(k\) 个顺串要合并,因此我们需要不断的在 \(k\) 个元素中选取最小值放到输出的磁带上,这个操作可以使用优先队列来实现,具体的例子可以从上图中看出。
尽管多路合并减少了趟数,但是由于 \(k\) 路合并的每一趟工作需要 \(k\) 个输入磁带和 \(k\) 个输出磁带,需要的磁带总数是 \(2k\),这一点就不太令人满意了,因为这大大增加了磁带的使用量,因此我们需要思考另一种优化方案。
多相(Polyphase)合并¶
多相合并于多路合并不同,它能够大大减小归并需要的磁带数目: \(k\) 路合并只需要 \(k+1\) 条磁带。
Example
设有三盘磁带 \(T_1\)、\(T_2\) 和 \(T_3\),在 \(T_1\) 上有一个输入文件,它将产生 34 个顺串。
一种选择是在 \(T_2\) 和 \(T_3\) 的每一盘磁带中放入 17 个顺串。然后我们可以将结果合并到 \(T_1\) 上,得到一盘有 17 个顺串的磁带。由于这时候所有的顺串都在一盘磁带上,因此我们现在必须把其中的一些顺串放到 \(T_2\) 上才能够进行后续的合并。
执行合并的逻辑方式是将前 8 个顺串从 \(T_1\) 拷贝到 \(T_2\) 并进行合并。这样的效果是,为了我们所做的每一趟合并,我们不得不附加额外的复制工作,而复制也和合并一样需要磁头的移动,是很昂贵的操作,因此这种方法并不好。
这么进行下去后,我们会发现总共需要 1 趟初始操作 + 6 趟工作,外加 5 次复制操作。
另一种选择是把原始的 34 个顺串不均衡地分成两份。设我们首先把 21 个顺串放到 \(T_2\) 上,把 13 个顺串放到 \(T_3\) 上。然后,我们把 \(T_3\) 上的 13 个顺串和 \(T_2\) 的前 13 个顺串合并到 \(T_1\) 上。我们就得到了具有 13 个顺串的 \(T_1\) 和 8 个顺串的 \(T_2\),再将它们的前 8 个顺串合并到 \(T_3\) 上,以此类推,最后一步操作就是存放在两个不同磁带上的两个顺串进行合并,得到最终的结果。
在第二种方案中,我们需要 1 趟初始操作 + 7 趟工作,而不需要任何复制操作。
从上面的例子中我们可以知道,顺串最初的分配对于整个外部排序而言十分重要,事实上,我们给出的最初 21+13 的分配是最优的,否则都可能出现上面的需要复制或者有长短不对齐导致浪费的情况。很容易发现,这两个数字都是斐波那契数。
Note
如果顺串的个数是一个斐波那契数 \(F_n\),那么分配这些顺串最好的方式是把它们分裂成两个斐波那契数 \(F_{n−1}\) 和 \(F_{n−2}\)。否则,为了将顺串的个数补足成一个斐波那契数,我们就必须用一些哑顺串(dummy run)来填补磁带。
我们还可以把上面的做法扩充到 \(k\) 路合并,此时我们需要第 \(k\) 阶斐波那契数用于分配顺串,其中 \(k\) 阶斐波那契数定义为 $$ F^{(k)}(n) = F^{(k)}(n-1) + F^{(k)}(n-2) + \cdots + F^{(k)}(n-k) $$ 辅以适当的初始条件 \(F^{(k)}(0) = F^{(k)}(1) = \cdots = F^{(k)}(k-2) = 0, F^{(k)}(k-1) = 1\)。
这样我们就可以用 \(k+1\) 盘磁带完成 \(k\) 路合并。
Tip
\(k\) 路合并的具体操作值得说明一下。事实上经过第一步拆分之后,我们每个磁带上的顺串个数应当分别为 $$ \begin{aligned} \text{磁带 1} & : F^{(k)}(n-1) + F^{(k)}(n-2) + \cdots + F^{(k)}(n-k) \\ \text{磁带 2} & : F^{(k)}(n-1) + F^{(k)}(n-2) + \cdots + F^{(k)}(n-k+1) \\ & \cdots \\ \text{磁带 k} & : F^{(k)}(n-1) \\ \text{磁带 k+1} & : 0 \end{aligned} $$ 然后我们合并磁带 \(1\) 到 \(k\) 的前 \(F^{(k)}(n-1)\) 个顺串,将合并后的结果放到磁带 \(k+1\) 上,此时不同磁带上的顺串个数变为 $$ \begin{aligned} \text{磁带 1} & : F^{(k)}(n-2) + F^{(k)}(n-3) + \cdots + F^{(k)}(n-k) \\ \text{磁带 2} & : F^{(k)}(n-2) + F^{(k)}(n-3) + \cdots + F^{(k)}(n-k+1) \\ & \cdots \\ \text{磁带 k} & : 0 \\ \text{磁带 k+1} & : F^{(k)}(n-1) = F^{(k)}(n-2) + F^{(k)}(n-3) + \cdots + F^{(k)}(n-k-1) \end{aligned} $$ 可以看出此时我们的顺串个数分布与之前的是类似的,只是下标减了 1,因此我们可以继续重复这个操作,合并除了磁带 \(k\) 之外的所有磁带的前 \(F^{(k)}(n-2)\) 个顺串,将结果放到磁带 \(k\) 上,以此类推,直到顺串数量为 1,这时候我们就完成了整个排序。
缓存并行处理¶
实际上我们实现外部排序的时候,肯定是一块一块地读入数据的,并且也不是每比较了一次就要往磁盘写一个元素,否则每次比较完都要等待磁盘处理很长时间才能进行下一次比较,非常耗时。
实际上在 2 路合并中,我们应该是把内存划分为 2 个输入缓存区(buffer),1 个输出缓存区,输入缓存区用来存放从输入磁盘读入的数据,然后两个输入缓存区的数据比较之后的排序结果先放到输出缓存区,当输出缓存区满了之后再一次性把这些数据写入输出磁盘。
然而这样的实现仍然存在问题:输出缓存区要写回磁盘的时候,内存什么都干不了,需要等待写回操作结束后才能继续比较并把比较结果写到输出缓存区上。因此我们可以划分出两个输出缓存区,当其中一个满了正在写回磁盘的时候,另一个输出缓存区继续接收数据,这样就可以实现并行处理了——一个在内存里操作,一个进行 I/O 交互。
这时候我们可以注意到,如果仍然是 2 个输入缓存,当所有输入缓存中的数据都比较完了,这时我们也要被迫停止等待新的数据从输入磁盘中读入。因此我们也可以划分出 4 个输入缓存,这样当其中两个输入缓存中的数据正在比较的时候,另外两个输入缓存可以同时并行地读入新的数据。
Idea
这时候我们就会发现,在 \(k\) 路合并中,如果 \(k\) 太大,那么尽管它减少了工作趟数,但是会把内存划分为 \(2k\) 个输入缓存区和 \(2\) 个输出缓存区,这样当 \(k\) 很大的时候,我们的输入缓存就会被划分得很细,一次能读入输入缓存的数据量就会减小(也就是 block 大小降低),那么我们的 I/O 操作就会变多,因此结果也不一定更好
替换选择¶
最后我们将要考虑的优化方向是优化顺串的构造。迄今我们已经用到的策略是每个顺串的大小都是内存的大小,然而事实上这一长度还可以进一步扩展。
当某个内存位置上的元素已经写入磁盘后,这个位置就可以空出来给数组后续的元素使用。只要后续的元素比现在写入的更大,就可以直接往顺串后面添加该元素,因为这时候新元素的加入并不会影响升序排列的性质。
如上面图片所示,我们在内存中维护一个优先队列,每次从队列中取出比当前顺串中最后一个元素更大的元素中最小的那一个写入磁盘,然后再从磁盘中读入一个元素放入队列中,直到队列中的所有元素都比顺串的最后一个要小,就开启一个新的顺串。
上述方法通常称为替换选择(replacement selection)。显然,当原数组的顺序已经比较接近最终的顺序时,替换选择能够得到更长的顺串。当然,在平均情况下,替换选择生成的顺串长度为 \(2M\),其中 \(M\) 是内存的大小。这意味着我们使用替换选择算法能使得顺串的数量降低,因此之后合并的趟数也会减少,从而减少了 I/O 操作的次数。
当然,在生成了不同长度的顺串之后,我们还需要考虑如何合并这些顺串是最优的。从 PPT 中的例子可以知道,最优解就是使用哈夫曼树,因为我们的目标是想让长的顺串尽可能少地参与合并,那么显然贪心地不断选择最小的两个顺串合并是最优的。