zoukankan      html  css  js  c++  java
  • 谜题94:迷失在混乱中

    下面的shuffle方法声称它将公平的打乱它的输入数组的次序。换句话说,假设其使用的伪随机数发生器是公正的,它将会以均等的概率产生各种排列的数组。它真的兑现了它的诺言吗?如果没有,你将如何订正它呢?

    
    import java.util.Random;
    
    public class Shuffle {
    
        private static Random rnd = new Random();
    
        public static void shuffle(Object[] a) {
    
            for(int i = 0; i < a.length; i++)
    
                swap(a, i, rnd.nextInt(a.length));
    
        }
    
        private static void swap(Object[] a, int i, int j) {
    
            Object tmp = a[i];
    
            a[i] = a[j];
    
            a[j] = tmp;
    
        }
    
    }
    

    看看这个shuffle方法,它并没有什么明显的错误。它遍历了整个数组,将随机抽取的元素互换位置。这会公平地将数组打乱,对吗?不对。“它没有明显的错误”和“它明显没有错误”,这2种说法是很不同的。在这里,有很严重的错误,但是它并不明显,除非你专门研究算法。

    如果你使用一个长度为n的数组作为参数去调用shuffle方法,这个循环体会执行n次。在每次执行中,这个方法会选取从0到n-1这n个整数中的一个。所以,该方法就有nn 种不同的执行动作。我们假设随机数发生器是公平的,那么每一种执行动作出现的概率是相等的。每一种执行动作都产生数组的一种排列。但是,这里就有一个小问题:对于一个长度为n的数组来说,只有n!种不同的排列。(在n之后的感叹号表示了阶乘(factorial)操作:n的阶乘定义为n×(n-1) ×(n-2) ×…×1。)问题在于,对于任何大于2的n,nn 都无法被n!整除,因为n!包含了从2到n的所有质数因子,而nn 只包含了n所包含的质数因子。这就毫无疑问的证明了shuffle方法将会更多地产生某些排列。

    为了使这个问题更具体一些,让我们来考虑一个包含了字符串”a”,”b”,”c”的长度为3的数组。此时shuffle方法就有33 = 27种执行动作。这些动作出现机率相同,并且都会产生某个排列。数组有3! = 6种不同的排列:{“a”,”b”,”c”},{“a”,”c”,”b”},{“b”,”a”,”c”},{“b”,”c”,”a”},{“c”,”a”,”b”}和{“c”,”b”,”a”}。由于27不能被6整除,比起其他的排列,某些排列肯定会被更多的执行动作所产生,所以shuffle方法并不是公平的。

    这里的一个问题就是,上述的证明只是证明了shuffle方法确实存在偏差,而并没有提供任何这种偏差的感性材料。有时候深入了解的最好办法就是动手实验。我们让该方法操作“恒等数组”(identity array,即满足a[i]=i的数组a),然后测试程序将计算每个位置上的元素的期望值(expected value)。宽松的说,这个期望值,就是在重复运行shuffle方法的时候,你在数组的某个位置上看到的所有数值的平均值。如果shuffle方法是公平的,那么每个位置的元素的期望值应该是相等的:((n-1)/2)。图10.1显示了在一个长度为9的数组中各个元素的期望值。请注意这张图特殊的形状:开始的时候比较低,然后增长超过了公平值(4),然后在最后一个元素下降到公平值。

    为什么这张图会有这种形状呢?我们不知道具体的细节,但是我们会有一些直觉上的认识。让我们把注意力集中到数组的第一个元素上。当循环体第一次执行之后,它会有正确的期望值(n-1)/2。然而在第2次执行中,有n分之1的可能性,随机数发生器会返回0且数组第一个元素的值会被设为1或0。也就是说,第2次执行系统地减少了第一个元素的期望值。在第3次执行中,也会有n分之1的可能性,第一个元素的值会被设为2、1或者0,然后就这么继续下去。在循环的前n/2次执行中,第一个元素的期望值是减少的。在后n/2次执行中,它的期望值是增加的,但是再也达不到它的公平值了。请注意,数组的最后一个元素肯定会有正确的期望值,因为在方法执行的最后一步,就是在数组的所有元素中为其选择一个值。

    好了,我们的shuffle方法是坏掉了。我们怎么修复它呢?使用类库中提供的shuffle方法:

    
    import java.util.*;
    
    public static void shuffle(Object[] a) {
    
        Collections.shuffle(Arrays.asList(a));
    
    }
    

    如果库中有可以满足你需要的方法,请务必使用它[EJ Item 30]。一般来说,库提供了高效的解决方案,并且可以让你付出最小的努力。

    另外,在你忍受了所有这些数学的东西之后,如果不告诉你如何修复这个坏掉的shuffle方法是不公平的。修复方法是非常直接的。在循环体中,将当前的元素和某个在当前元素与数组末尾元素之间的所有元素中随机选择出来的元素进行互换。不要去碰那些你已经进行过值互换的元素。这本质上也就是库中的方法所使用的算法:

    
    public static void shuffle(Object[] a) {
    
        for(int i = 0; i < a.length; i++)
    
               swap(a, i, i + rnd.nextInt(a.length - i));
    
    }
    

    使用归纳法很容易证明这个方法是公平的。最基础情况,让我们观察长度为0的数组,这显然是公平的。根据归纳法的步骤,如果你将这个方法用在一个长度n>0的数组上,它会为这个数组的0位置上的元素随机选择一个值。然后,它会遍历数组剩下的元素:在每个位置上,它会在“子数组”中随机选择一个元素,这个子数组从当前位置开始到原数组的末尾。对于从位置1到原数组末尾的这个长度为n-1的子数组来说,如果将该方法作用在这个子数组上,它实际上也是在做上述的事。这就完成了证明。它同时也提供了shuffle方法的递归形式,它的细节就留给读者作为练习了。

    你可能会认为到此为止就是故事的全部内容了,但却还有一部分内容。你设想过这个经过修复的shuffle方法会等概率的产生一个表示52张牌的52个元素的数组的所有排列吗?毕竟我们只是证明了它是公平的。在这里你可能不会很惊讶地发现答案很显然是“不”。这里的问题是,在谜题的开始,我们做出了“使用的伪随机数发生器是公平的”这一假设。但是它不是。

    这个随机数发生器,java.util.Random,使用的是一个64位的种子,而它产生的随机数完全是由这个种子决定的。52张牌有52!种排列,而种子却只有264个。它能够覆盖的排列占所有排列的多少呢?你相信是百分之2.3×10-47吗?这只是委婉地表示了“实际上就没怎么覆盖”。如果你使用java.security.SecureRandom代替java.util.Random,你会得到一个160位的种子,但是它给你带来的东西少得惊人:对于元素个数大于40的数组,这个shuffle方法仍然不能返回它的某些排列(因为40!>2160) 。对于一个52个元素的数组,你只能获得所有可能的排列的百分之1.8×10-18 。

    这难道意味着你在洗牌的时候不能相信这些伪随机数发生器吗?这要看情况。它们确实只能产生所有可能排列的微不足道的一部分,但是它们没有我们前面所看到的那种系统性的偏差。公平地讲,这些发生器在非正式的场景中已经足够好用了。如果你需要一个尖端的随机数发生器,那你就需要到别的什么地方去寻找了。总之,像很多算法一样,打乱一个数组是需要慎重对待的。这么做很容易犯错并且很难发现错误。在其他条件相似的情况下,你应该优先使用类库而不是手写的代码。如果你想学习更多的关于本谜题的论题的内容,请参见[Knuth98 3.4.2]。

  • 相关阅读:
    C# 类 (7)
    C# 类 (6) -继承
    C# 类 (5)
    c# 类(4)
    C# 类(3)
    c# 类(2)
    C# 类 (1)
    C# 零碎知识点
    VINS_Fusion 初始化过程
    视觉SLAM(一)预备课程与基础知识
  • 原文地址:https://www.cnblogs.com/yuyu666/p/9842718.html
Copyright © 2011-2022 走看看