一般说明
我个人关于使用概率算法的正确性的方法:如果您知道如何证明它是正确的,那么它可能是正确的。如果不这样做,那肯定是错误的。
换句话说,尝试分析每种可能的算法通常是没有希望的:必须一直寻找一种算法,直到找到可以证明正确的算法为止。
通过计算分布分析随机算法
我知道一种“自动”分析混洗(或更普遍地说是随机使用算法)的方法,该方法比简单的“进行大量测试并检查均匀性”要强。您可以机械地计算与算法的每个输入关联的分布。
通常的想法是,随机使用算法探索了可能性世界的一部分。每次算法要求一组中的随机元素(掷硬币时{ true
,false
}),算法都会有两种可能的结果,并且选择其中一种。您可以更改算法,以使其不再返回可能的结果之一,而是并行探索所有解决方案,并返回具有相关分布的所有可能的结果。
通常,这需要深度重写算法。如果您的语言支持带分隔符的延续,则不必这样做。您可以在函数内实现“探索所有可能的结果”,以请求一个随机元素(这种想法是,随机生成器而不是返回结果,而是捕获与程序关联的延续并以所有不同的结果运行它)。有关此方法的示例,请参见oleg的HANSEI。
一个中间的解决方案,可能是较不为人知的解决方案,是将这种“可能的结果世界”表示为monad,并使用Haskell这样的语言来进行monadic编程。这是在Haskell中使用概率包的概率monad实现算法变式¹的示例实现:
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实现的一种变体。如果我不使用数据透视,就像您的情况一样,输入大小不会在每一步都减小:该算法还会考虑所有输入都在左列表(或右列表)中并在无限循环中丢失的情况。这是monad概率实现的弱点(如果算法的非终止概率为0,则分布计算可能仍会发散),我尚不知道如何解决。
基于排序的洗牌
这是一个简单的算法,我确信自己可以证明是正确的:
- 为集合中的每个元素选择一个随机密钥。
- 如果键不是完全不同的,请从步骤1重新开始。
- 通过这些随机键对集合进行排序。
如果您知道发生碰撞的概率(选择的两个随机数相等)足够低,则可以省略第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的基于mergesort的解决方案是一个不错的选择。
算法上的考虑:先前算法的复杂性取决于所有键都不同的概率。如果将它们选择为32位整数,则特定键与另一个键发生冲突的概率约为40亿。假设选择一个随机数为O(1),则按这些键排序的结果为O(n log n)。
如果您使用无限的位串,则不必重新启动选择,但是复杂度则与“平均计算流的元素数”有关。我猜想它平均为O(log n)(因此总共仍然为O(n log n)),但没有证据。
...而且我认为您的算法有效
经过更多的反思,我认为(像douplep一样)您的实现是正确的。这是一个非正式的解释。
列表中的每个元素都经过几次random:uniform() < 0.5
测试。对于一个元素,您可以将这些测试的结果列表关联为布尔值或{ 0
,1
}列表。在算法开始时,您不知道与这些数字相关的列表。第一次partition
调用后,您知道每个列表的第一个元素,等等。当您的算法返回时,测试列表是完全已知的,并且根据这些列表对元素进行排序(按字典顺序排序,或视为实数的二进制表示形式)数字)。
因此,您的算法等效于按无限位字符串键排序。对列表进行分区的操作,使人想起quicksort在数据透视元素上的分区,实际上是一种方法,对于位串中的给定位置,将具有评估0
的元素与具有评估的元素分开1
。
排序是统一的,因为位串都不同。确实,n
在递归shuffle
调用depth期间,两个实数等于第-位的元素位于分区的同一侧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个惰性流是否不同,以及运行时间更长的可能性呈指数下降。