这个洗牌算法有什么问题,我怎么知道?

79

背景说明,我知道费雪耶兹洗牌算法。这是一种O(n)复杂度的优秀洗牌算法,保证了均匀性,在支持数组原地更新的编程环境中(大多数即使不是全部——命令式编程环境)都可以使用它,如果不使用就是傻瓜行为。

遗憾的是,函数式编程世界不允许你访问可变状态。

由于费雪耶兹洗牌算法的存在,很难找到有关如何设计洗牌算法的文献。只有极少数文章简要地提及此问题后,实际上说:“那么,这里有费雪耶兹算法,这就是你需要知道的全部内容。” 最终,我不得不想出自己的解决方案。

我提出的解决方案如下,可随机打乱任何数据列表:

  • 如果列表为空,请返回空集。
  • 如果列表只有一个项目,请返回该单个项目。
  • 如果列表非空,请使用随机数生成器对列表进行分区,并对每个分区递归应用算法,然后组装结果。

在Erlang代码中,它看起来像这样:

shuffle([])  -> [];
shuffle([L]) -> [L];
shuffle(L)   ->
  {Left, Right} = lists:partition(fun(_) -> 
                                    random:uniform() < 0.5 
                                  end, L),
  shuffle(Left) ++ shuffle(Right).

(如果您认为这看起来像是一个疯狂的快排,那基本上就是这样。)

所以这里是我的问题:使得寻找不是Fisher-Yates算法的洗牌算法难以找到的相同情况,也使得找到分析洗牌算法的工具同样困难。我可以找到很多关于分析伪随机数生成器(uniformity、periodicity等)的文献,但在如何分析洗牌方面却很少有信息。(事实上,我在分析洗牌方面找到的一些信息是完全错误的--可以通过简单的技巧轻易地欺骗过去。)

所以我的问题是:我该如何分析我的洗牌算法(假设上面的random:uniform()调用能够生成具有良好特性的适当随机数)? 在进行1..100的整数列表的100,000次运行后,有哪些数学工具可供我判断是否得到了合理的洗牌结果? 我已经进行了一些自己的测试(例如比较洗牌中的增量和减量),但我想知道更多。

如果有任何有关洗牌算法本身的洞察力,那也将不胜感激。


这个问题的答案可能会有所帮助:https://dev59.com/uUrSa4cB1Zd3GeqPUCo8。同时,值得一提的是,可以看一下Knuth对Fisher-Yates算法的分析(参见您提供的维基百科文章中的引用)。 - Alex Mendes da Costa
4
我建议你将这个问题发到MathOverflow上。归纳证明它按预期工作似乎可以归结为计算一行的总和。(但我非常肯定它是正确的,虽然不能保证在任何给定的时间内停止计算)。 - user319799
doublep > 我也认为这个算法是有效的。请查看我的帖子以获取详细解释。 - gasche
我认为无限减速在排序算法中被视为非常糟糕的现象? 另外,lists:split``lists:droplastlists:append不会使实施标准算法变得微不足道吗? - user645280
4个回答

77

总体评述

关于使用概率算法的正确性,我的个人方法是:如果你知道如何证明它是正确的,那么它可能是正确的;如果你不知道,那么它肯定是错误的。

换句话说,试图分析每一个可能出现的算法通常是无望的:你必须不断寻找一种算法,直到你找到一种你能够证明其正确性的算法。

通过计算分布来分析随机算法

我知道一种比简单的“进行大量测试并检查均匀性”更强的“自动”分析洗牌(或更普遍地说,随机使用算法)的方法。你可以机械地计算与算法的每个输入相关联的分布。

一般的想法是,随机使用算法探索了可能性的一部分。每次你的算法在集合中(例如{true, false}代表抛硬币)请求一个随机元素时,你的算法有两种可能的结果之一,并且其中一个被选择。你可以改变你的算法,使其不返回可能的结果之一,而是同时探索所有解决方案,并返回所有可能的结果和相关的分布。

一般来说,这需要深入重写你的算法。如果你的语言支持分界限定,那么你不必这样做;你可以在请求一个随机元素的函数内部实现“探索所有可能结果”的功能(想法是随机生成器不返回结果,而是捕获与你的程序相关联的继续运行,并以所有不同的结果运行它)。有关此方法的示例,请参见oleg's HANSEI

一种中介且可能不那么深奥的解决方案是将这个“可能结果的世界”表示为一个单子,使用像Haskell这样具有单子编程功能的语言。以下是使用probability包的概率单子在Haskell中实现您算法变体¹的示例:

import Numeric.Probability.Distribution

shuffleM :: (Num prob, Fractional prob) => [a] -> T prob [a]
shuffleM [] = return []
shuffleM [x] = return [x]
shuffleM (pivot:li) = do
        (left, right) <- partition li
        sleft <- shuffleM left
        sright <- shuffleM right
        return (sleft ++ [pivot] ++ sright)
  where partition [] = return ([], [])
        partition (x:xs) = do
                  (left, right) <- partition xs
                  uniform [(x:left, right), (left, x:right)]

你可以针对给定的输入运行它,并获得输出分布:
*Main> shuffleM [1,2]
fromFreqs [([1,2],0.5),([2,1],0.5)]
*Main> shuffleM [1,2,3]
fromFreqs
  [([2,1,3],0.25),([3,1,2],0.25),([1,2,3],0.125),
   ([1,3,2],0.125),([2,3,1],0.125),([3,2,1],0.125)]

你可以看到,这个算法在输入大小为2时是均匀的,但在输入大小为3时是不均匀的。
与基于测试的方法的区别在于,我们可以在有限步骤内获得绝对确定性:它可能相当大,因为它涉及到对可能性世界的详尽探索(但通常小于2^N,因为有类似结果的分解),但如果返回一个非均匀分布,我们就确定该算法是错误的。当然,如果它对于[1..N]1 <= N <= 100返回了均匀分布,那么你只知道你的算法在长度为100的列表上是均匀的;它仍然可能是错误的。
¹:这个算法是你的Erlang实现的变体,因为它处理了特定的枢轴。如果我像你一样不使用枢轴,那么输入大小就不再每次缩小了:算法还考虑了所有输入都在左侧列表(或右侧列表)的情况,并陷入了无限循环。这是概率单子实现的弱点(如果算法具有非终止的概率0,则分布计算仍可能发散),我还不知道如何解决。
基于排序的洗牌
这里是一个简单的算法,我相信我可以证明它的正确性:
1. 为你的集合中的每个元素选择一个随机键。 2. 如果这些键不全都不同,则从步骤1重新开始。 3. 根据这些随机键对集合进行排序。
如果你知道碰撞(两个随机选择的数字相等)的概率足够低,可以省略步骤2,但如果没有它,洗牌不是完全均匀的。如果您在[1..N]中选择键,其中N是您的集合的长度,将会有很多碰撞(生日问题)。如果您选择32位整数作为密钥,在实践中冲突的概率很低,但仍然受到生日问题的影响。如果您使用无限(惰性评估)比特串作为密钥,而不是有限长度的密钥,则碰撞的概率变为0,不再需要检查是否不同。这是一个OCaml中的洗牌实现,使用惰性实数作为无限比特串:
type 'a stream = Cons of 'a * 'a stream lazy_t

let rec real_number () =
  Cons (Random.bool (), lazy (real_number ()))

let rec compare_real a b = match a, b with
| Cons (true, _), Cons (false, _) -> 1
| Cons (false, _), Cons (true, _) -> -1
| Cons (_, lazy a'), Cons (_, lazy b') ->
    compare_real a' b'

let shuffle list =
  List.map snd
    (List.sort (fun (ra, _) (rb, _) -> compare_real ra rb)
       (List.map (fun x -> real_number (), x) list))

还有其他的"纯洗牌"方法。一种不错的方法是apfelmus的基于归并排序的解决方案

算法考虑:先前算法的复杂度取决于所有键都不同的概率。如果将它们选为32位整数,那么特定键与另一个键发生冲突的概率就是4十亿分之一。按这些键排序是O(n log n),假设随机选择一个数字是O(1)。

如果你有无限的比特串,你永远不需要重新开始选择,但复杂度与“平均评估流元素的数量”有关。我猜平均值是O(log n)(因此总共仍然是O(n log n)),但没有证明。

...而且我认为你的算法可行

经过更多的思考,我认为(像douplep一样),你的实现是正确的。以下是一个非正式的解释。

列表中的每个元素都会被多个random:uniform() < 0.5测试所测试。对于一个元素,你可以将那些测试的结果作为一个布尔值或{0, 1}的列表与之关联。在算法开始时,你不知道任何数字关联的列表。在第一次partition调用之后,你知道每个列表的第一个元素,等等。当你的算法返回时,测试列表完全已知,元素按照这些列表进行排序(按字典顺序排序,或者被视为实数的二进制表示)。

因此,您的算法相当于按无限位字符串键进行排序。分割列表的操作,类似于快速排序对枢轴元素的分割,实际上是一种根据二进制位中给定位置,将估值为0和估值为1的元素分开的方法。

由于位字符串都不同,因此排序是均匀的。事实上,两个具有相等实数值的元素在第n位上相等,在递归shuffle调用的深度n期间发生的分区的同一侧。该算法仅在所有分区结果为空或单件时终止:所有元素都已通过至少一个测试分离,并且具有一个不同的二进制小数。

概率终止

关于您的算法(或我的等效基于排序的方法)的一个微妙点是,终止条件是概率性的。Fisher-Yates始终在已知步数(数组中的元素数)之后终止。使用您的算法,终止取决于随机数生成器的输出。

可能会出现使您的算法发散而不是终止的输出。例如,如果随机数生成器总是输出0,则每个partition调用都将返回未更改的输入列表,在其中递归调用shuffle:您将无限循环。

然而,如果您确信您的随机数生成器是公平的,即不作弊并始终返回独立均匀分布的结果,则这不是问题。在这种情况下,测试random:uniform() < 0.5始终返回true(或false)的概率恰好为0:

  • 前N次调用返回true的概率为2^{-N}
  • 所有调用都返回true的概率是无限交集的概率,对于所有N,第一次N次调用返回0的事件,它是2^{-N}的下确界极限¹,即0

¹:有关数学细节,请参见http://en.wikipedia.org/wiki/Measure_(mathematics)#Measures_of_infinite_intersections_of_measurable_sets

更一般地,仅当一些元素与相同的布尔流相关联时,算法才不会终止。这意味着至少有两个元素具有相同的布尔流。但是,两个随机布尔流相等的概率也是0:位置K上的数字相等的概率为1/2,因此前N个数字相等的概率为2^{-N},同样的分析适用。

因此,您知道您的算法具有概率1终止。这是比Fisher-Yates算法略弱的保证,后者总是终止。特别是,您容易受到恶意对手攻击的攻击,该对手可以控制您的随机数生成器。

使用更多的概率论,您也可以计算给定输入长度的算法运行时间分布。这超出了我的技术能力,但我认为这很好:我假设您只需要平均查看O(log N)个数字,就可以检查所有N个惰性流是否不同,并且更高运行时间的概率呈指数递减。

3
我的真正问题是,我应该用哪些经验性测试来判断我的洗牌器的输出是否被合理地洗牌了?例如,“为每个元素配对一个随机权重”的方法,在我有限的测试能力下表现不佳。(我反复测试了序列[1,2],发现存在巨大的不平衡。) - JUST MY correct OPINION
我编辑了一下,提到了你关于[min_int..max_int]的警告:你是对的,它不适用于大序列。我还包括了一个基于实数的排序实现。我同意Fisher-Yates更简单,但我不确定Oleg的建议是否如此。 - gasche
1
@AJMansfield:实际上,使用64位密钥,您只需要进行约50亿次选择就可以期望出现50%的碰撞。在进行100亿次选择后,碰撞的概率增加到了约93%。这种直觉上不符合常理的结果被称为生日问题。 - Pi Delport
128到256位密钥应该足以将冲突的概率降低到低于硬件故障的水平,适用于任何适合于今天平均台式电脑内存的集合(但这并不考虑更大的数据集、未来的内存增长或多个洗牌)。换句话说,你可以让它工作,但当Fisher-Yates洗牌可用时,为什么要那么麻烦和低效呢? - Pi Delport
我想知道,当列表变得足够小(比如说,可以完全适应内存),是否值得快捷地转换为Fischer-Yates算法。在大型列表中,建议的算法很可能将列表分成大约一半,但不太可能得到大小为1的列表(如果我理解正确,则每个n的P = 1 /(n-1)!)。 - R. Wang
显示剩余10条评论

24

你的算法是基于排序的随机洗牌,正如维基百科文章所讨论的那样。

一般而言,基于排序的随机洗牌的计算复杂度与底层排序算法相同(例如使用快速排序的洗牌的平均时间复杂度为O(n log n), 最坏情况下为O(n²)), 虽然分布不是完全均匀的,但对于大多数实际目的来说,它应该足够接近均匀。

Oleg Kiselyov提供了以下文章/讨论:

它更详细地介绍了基于排序的洗牌的限制,并提供了两种改编自Fischer-Yates策略的方法:一个天真的O(n²)方法和一个基于二叉树的O(n log n)方法。

遗憾的是,函数式编程世界不允许您访问可变状态。

这不是真的:虽然纯函数式编程避免了副作用,但它通过一级效果支持对可变状态的访问,而无需副作用。

在这种情况下,您可以使用Haskell的可变数组来实现变异Fischer-Yates算法,如本教程所述:

附录

你的洗牌排序的具体基础实际上是无限键基数排序:正如gasche所指出的那样,每个分区对应一个数字分组。

这种方法的主要缺点与任何其他无限键排序洗牌相同:没有终止保证。尽管随着比较的进行,终止的可能性增加,但从不会有上限:最坏情况下的复杂度为O(∞)。


4
你认为它不有效的原因是什么? - Pi Delport
可以很容易地修复基于简单排序的洗牌,使其完全均匀(前提是底层随机数生成器也是完全均匀的),而无需经过Oleg纯解决方案的额外复杂性。当在排序过程中比较两个相等的元素时,您会失去均匀性:必须做出任意选择以对它们进行排序。您可以选择保证永远不会相等的权重,例如随机选择的实数(浮点数,甚至更好的惰性布尔流)。参见haskell-beginners - gasche
关于在不考虑不同的排序键的情况下完成步骤1-3,然后搜索连续排序键的任何运行并递归地对其进行重排,您怎么看? - supercat
supercat:同样的问题:递归没有保证一定会终止。你只能计算它在N步之后终止的可能性,这个可能性趋近于1(但永远不会达到1)。此外,除非你解决其他问题,否则得到的洗牌仍然不会是均匀的。 - Pi Delport
gasche:感谢您的礼貌,我很欣赏!回复:这不是我,而是论文的作者将其称为“保证”终止,只是为了将其与“概率性终止”区分开来。其他人只是简单地称之为“终止”,在该领域中这就是名称。混淆似乎来自于作者说“它终止的概率为1”,当他们实际上是指“它以概率终止的概率为1”,使用他们修改过的、非有限(和非标准)定义的“终止”。 - Pi Delport
显示剩余21条评论

3

我之前做过类似的事情,特别是你可能会对Clojure的向量感兴趣,它们是功能性和不可变的,但仍具有O(1)随机访问/更新特性。这两个代码片段有几个实现“从这个M大小的列表中随机取N个元素”的方法;如果您让N=M,其中至少一个将转化为Fisher-Yates的功能实现。

https://gist.github.com/805546

https://gist.github.com/805747


1

基于如何测试随机性(以洗牌为例),我建议:

洗牌(中等大小的)由相等数量的零和一组成的数组。重复并连接直到厌倦。将它们用作diehard测试的输入。如果您有一个好的洗牌,则应该生成随机的零和一序列(附带条件是在中等大小的数组边界处累积多余的零(或一)为零,您希望测试检测到,但是“中等”越大,它们就越不可能这样做)。

请注意,测试可以因以下三个原因拒绝您的洗牌:

  • 洗牌算法不好,
  • 洗牌器或初始化期间使用的随机数生成器不好,或者
  • 测试实现不好。

如果任何测试被拒绝,您将需要确定哪种情况。

有各种各样的diehard tests改编版(为了解决某些数字,我使用了源代码来自diehard页面)。适应的主要机制是使洗牌算法作为均匀分布随机位的来源。

  • 生日间隔:在一个由n个零组成的数组中,插入log n个一。洗牌。重复直到无聊为止。构建一次间隔分布,与指数分布进行比较。您应该使用不同的初始化策略执行此实验 - 前面的那些、后面的那些、中间在一起的那些、随机分散的那些。(后者具有最大的风险,即错误的初始化随机化(关于洗牌随机化)可能导致拒绝洗牌)。这实际上可以使用相同值的块来完成,但是存在一个问题,即它会在分布中引入相关性(一个1和一个2不能在单个洗牌中位于同一位置)。
  • 重叠排列:多次洗牌五个值。验证120种结果几乎是等可能的。(卡方检验,119自由度 - diehard测试(cdoperm5.c)使用99自由度,但这主要是由于使用输入序列的重叠子序列导致的顺序相关性的产物。)
  • 矩阵秩:从4608位的0和1的洗牌中选择6个不重叠的8位子字符串。将它们视为一个6乘8的二进制矩阵,并计算其秩。重复100,000个矩阵。(将0-4的等级汇集在一起。然后等级要么是6、5,要么是0-4)期望的等级比例为0.773118、0.217439、0.009443。用两个自由度的观察比较卡方分布。31乘31和32乘32测试类似。分别汇集0-28和0-29的等级。预期的比例为0.2887880952、0.5775761902、0.1283502644、0.0052854502。卡方检验有三个自由度。

您可能还希望利用dieharder和/或ent来进行类似的适应性测试。

等等...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接