虽然您可以对随机键使用外部排序,正如OldCurmudgeon 所建议的那样,但随机键不是必需的。您可以将内存中的数据块打乱,然后按照aldel 的建议通过“随机合并”将它们连接起来。
值得更清楚地说明“随机合并”的含义。给定两个大小相等的混洗序列,随机合并的行为与merge sort 中的完全相同,不同之处在于,使用来自 0 和 1 的混洗序列的布尔值选择要添加到合并列表中的下一项,完全零和一一样多。 (在归并排序中,将使用比较进行选择。)
证明它
我断言这行得通是不够的。我们怎么知道这个过程给出了一个打乱的序列,这样每个排序都是同样可能的?可以给出带有图表和一些计算的证明草图。
首先,定义。假设我们有 N 唯一项,其中N 是偶数,M = N / 2。 N 项目以两个M-item 序列 提供给我们,标记为0 和1,保证以随机顺序排列。合并它们的过程会产生一个序列N项目,这样每个项目来自序列0或序列1,并且来自每个序列的相同数量的项目。它看起来像这样:
0: a b c d
1: w x y z
N: a w x b y c d z
请注意,虽然0 和1 中的项目看起来是有序的,但它们在这里只是标签,顺序没有任何意义。它只是将0 和1 的顺序连接到N 的顺序。
由于我们可以从标签中分辨出每个项目来自哪个序列,因此我们可以创建一个由 0 和 1 组成的“源”序列。打电话给c。
c: 0 1 1 0 1 0 0 1
根据上述定义,c 中的零总是与零一样多。
现在观察,对于N 中任何给定的标签排序,我们可以直接复制c 序列,因为标签保留了有关它们来自的序列的信息。给定N 和c,我们可以重现0 和1 序列。所以我们知道从序列N 到一个三元组(0, 1, c) 总是有一条路径。换句话说,我们有一个 reverse 函数 r,它从 N 标签到三元组 (0, 1, c) -- r(N) = (0, 1, c) 的所有排序集合中定义。
我们还有一个来自任何三元组r(n) 的转发函数f,它根据c 的值简单地重新合并0 和1。这两个函数一起表明r(N) 的输出和N 的排序之间存在一一对应关系。
但我们真正想要证明的是,这种一一对应是详尽无遗的——也就是说,我们想要证明N没有额外的排序' 不对应于任何三元组,并且没有额外的三元组不对应于N 的任何排序。如果我们能证明这一点,那么我们可以通过以均匀随机的方式选择三元组(0, 1, c),以均匀随机的方式选择N的排序。
我们可以通过计数箱子来完成证明的最后一部分。假设每个可能的三元组都有一个 bin。然后我们将N 的每一个订单都放入bin 中,以获得r(N) 给我们的三元组。如果 bin 的数量与 orderings 的数量完全相同,那么我们就有了详尽的一对一对应关系。
从组合学中,我们知道N 唯一标签的排序数是N!。我们也知道0和1的订购数都是M!。而且我们知道c的可能序列数为N choose M,与N! / (M! * (N - M)!)相同。
这意味着总共有
M! * M! * N! / (M! * (N - M)!)
三倍。但是N = 2 * M,所以N - M = M,上面的简化为
M! * M! * N! / (M! * M!)
那只是N!。 QED。
实施
要以均匀随机的方式选取三元组,我们必须以均匀随机的方式选取三元组的每个元素。对于0 和1,我们在内存中使用简单的Fisher-Yates shuffle 来实现这一点。唯一剩下的障碍是生成正确的 0 和 1 序列。
重要的是 -- 重要! -- 只生成具有相等数量的零和一的序列。否则,您没有从具有统一概率的Choose(N, M) 序列中进行选择,并且您的随机播放可能有偏差。最明显的方法是对包含相等数量的 0 和 1 的序列进行洗牌......但问题的整个前提是我们无法在内存中容纳那么多的 0 和 1!所以我们需要一种方法来生成随机的 0 和 1 序列,这些序列受到约束,使得 0 的数量与 1 的数量完全相同。
要以概率一致的方式执行此操作,我们可以模拟从瓮中绘制标记为 0 或 1 的球,无需替换。假设我们从 50 个 0 球和 50 个 1 球开始。如果我们对瓮中每种球的数量进行计数,就可以保持一个选择其中一个的运行概率,这样最终的结果就不会有偏差。 (可能类似于 Python 的)伪代码是这样的:
def generate_choices(N, M):
n0 = M
n1 = N - M
while n0 + n1 > 0:
if randrange(0, n0 + n1) < n0:
yield 0
n0 -= 1
else:
yield 1
n1 -= 1
由于浮点错误,这可能不是完美,但它会非常接近完美。
算法的最后一部分至关重要。详尽地通过上述证明可以清楚地表明,其他生成 1 和 0 的方法不会给我们适当的洗牌。
在真实数据中执行多次合并
还有一些实际问题。上面的论点假设了一个完美平衡的合并,它还假设你的数据只有内存的两倍。这两种假设都不可能成立。
事实证明,拳头并不是一个大问题,因为上述论点实际上并不需要相同大小的列表。只是如果列表大小不同,计算会稍微复杂一些。如果您通过上述将M 列表1 替换为N - M,则所有详细信息都以相同的方式排列。 (伪代码的编写方式也适用于任何大于零且小于N 的M。然后将恰好有M 零和M - N 1。)
第二个意思是,在实践中,可能会有很多很多的块以这种方式合并。该过程继承了合并排序的几个属性——特别是,它要求对于K 块,您必须大致执行K / 2 合并,然后执行K / 4 合并,依此类推,直到所有数据都已合并。每批合并将遍历整个数据集,大约有log2(K) 批,运行时间为O(N * log(K))。普通的 Fisher-Yates 洗牌在N 中将是严格线性的,因此理论上对于非常大的K 会更快。但是在K 变得非常非常大之前,惩罚可能会比磁盘查找惩罚小得多。
因此,这种方法的好处来自智能 IO 管理。而对于 SSD,它甚至可能不值得——寻找惩罚可能不足以证明多次合并的开销是合理的。 Paul Hankin 的回答有一些实用技巧,可以帮助您思考所提出的实际问题。
一次合并所有数据
进行多个二进制合并的另一种方法是一次合并所有块——这在理论上是可能的,并且可能导致O(N) 算法。 c 中的值的随机数生成算法需要生成从 0 到 K - 1 的标签,以便最终输出具有每个类别的正确数量的标签。 (换句话说,如果您将三个块与10、12 和13 项目合并,那么c 的最终值将需要有0 十次,1 十二次,和2 十三次。)
我认为可能有一个O(N) 时间、O(1) 空间算法可以做到这一点,如果我能找到一个或解决一个问题,我会在这里发布。结果将是一个真正的O(N) shuffle,就像 Paul Hankin 在他的回答结尾所描述的那样。