Java 数据结构和算法 - 随机

Java 数据结构和算法 - 随机

很多时候,需要使用随机数做计算。比如现代加密和仿真系统,甚至搜索和排序算法也依赖随机数生成器。实现一个好的随机数生成器还是比较困难的。

随机数生成器

随机数怎么生成呢?真正的随机性是无法用计算机实现的,因为获取任何数所依赖的算法不可能是随机的。通常,可以生成伪随机数,或者数看上去是随机的,因为它满足了随机数的很多属性。
假如我们需要仿真抛硬币。一种办法是检查系统时钟。系统时钟维护秒数,作为当前时间的一部分。如果是偶数,我们返回0;如果是奇数,我们返回1。如果我们需要一系列随机数,这样做效果不好。一秒时间太长,程序运行的时候,时钟还来不及改变,生成的总是0或者总是1,这样很难生成随机数序列。甚至时间的记录单位是毫秒(或者更小)的时候,生成的数字序列还是不够随机,因为程序调用的时间基本上还是相同。
我们需要的是一系列伪随机数,和随机数序列的属性相同。假如想得到0-999之间的均匀分布(其他分布也很常用,大多数分布源自均匀分布)的随机数,在指定范围内,不同数字的出现机会是相等的。
真正的0-999的均匀分布的序列应该有下列属性:

  • 第一个数同样可能是0-999
  • 第n个数同样可能是0-999
  • 所有生成的数的预期的平均值是499.5

这些属性不是特别严谨。比如,我们根据系统时钟生成的第一个数是1,然后的每个数都加1。生成1000个数以后,所有的属性都满足了。但是没有满足更强的属性。
均匀分布的随机数序列还应该满足:

  • 两个连续随机数的和同样可能是偶数或者奇数
  • 随机生成的1000个数,应该有一些是重复的(大概368个数不会出现)

我们的数不满足这些属性。连续数的和总是奇数,数字也没重复。所以,上面的随机数生成器没有通过统计测试。

线性同余生成器是生成均匀分布的好的算法。它生成的X1、X2满足:
Xi+1 = AXi(mod M)
改成Java就是:

    x[i + 1] = A * x[i] % M

其中A和M是常量。注意,生成的数会小于M。需要给出X0来开始序列,这个初始化值就是随机数生成器的种子。如果
X0=0,序列就不是随机的,因为它生成的全都是0。如果小心选择了A和M,任何满足1 ≤ X0 < M条件的种子都是同等有效的。
如果M是素数,Xi肯定不是0。比如,如果M=11、A=7、种子X0=1,生成的数字序列是:

    7, 5, 2, 3, 10, 4, 6, 9, 8, 1, 7, 5, 2, ...

生成的是一个重复序列。我们的例子,在M-1=10个数字后重复(这个长度叫周期)。
如果M是素数,A是某些值的时候,周期是M-1,这样的随机数生成器叫做全周期线性同余生成器。A选其他值,得到的不是全周期。比如,
如果A=5、种子X0=1,序列的周期是5:

    5, 3, 4, 9, 1, 5, 3, 4, ...

如果我们选择M是一个31位的素数,对很多程序来说,周期就足够大了。31位素数M可以选231-1=2,147,483,647。对于这个素数,A一般选48,271,这样可以得到全周期线性同余生成器。
不幸的是,使用32位整数计算,乘法会溢出。如果坚持使用32位整数,返回就是随机性的一部分。所以,溢出是不可接受的,因为不能保证全周期。
如果Q和R是M/A的商和余数,可以这样写等式
Xi+1 = A(Xi(mod Q)) – R(Xi/Q) + Mδ(Xi)
并且以下条件成立:

  • 第一部分保证不溢出
  • 第二部分如果R<Q,也不溢出
  • 如果前两项之差为正,δ(Xi)是0;如果差为负,δ(Xi)是1

对于M和A的值,我们有Q = 44,488和R = 3,399。所以,可以生成随机数。

public class Random31 {
    private static final int A = 48271;
    private static final int M = 2147483647;
    private static final int Q = M / A;
    private static final int R = M % A;

    public Random31() {
        this((int) (System.nanoTime() % Integer.MAX_VALUE));
    }
    
    public Random31(int initialValue) {
        if (initialValue < 0) {
            initialValue += M;
            initialValue++;
        }

        state = initialValue;
        if (state <= 0)
            state = 1;
    }    

    public int nextInt() {
        int tmpState = A * (state % Q) - R * (state / Q);
        if (tmpState >= 0)
            state = tmpState;
        else
            state = tmpState + M;

        return state;
    }
    
    public int nextInt(int low, int high) {
        double partitionSize = M / (double) (high - low + 1);

        return (int) (nextInt() / partitionSize) + low;
    }    

    public long nextLong() {
        return ((((long) nextInt()) - 1) << 31) + nextInt();
    }        
}

最后,随机类也需要提供一个非均匀分布的随机数,后面会实现nextPoisson和nextNegExp。
看起来,我们在等式中添加常量可以生成更好的随机数生成器。比如
Xi+1 = (48,271Xi + 1) mod (231-1)
好像更随机。但是,当我们使用该等式
(48,271 ⋅ 179,424,105 + 1) mod (231-1) = 179,424,105
于是,如果种子是179,424,105,周期就是1,生成器多么脆弱。
你可能觉得所有机器的随机数生成器都是全周期的线性同余生成器,那你就错了。很多库的生成器基于下面的函数
Xi+1 = (AXi + C) mod 2B
其中B和机器的整数位数匹配,C是奇数。这些库,生成的Xi总是在奇数和偶数之间交替。实际上,最低k位的最好循环周期是2k,很多生成器的周期都比前面代码里的要小。下面的程序也是这样的形式,不过,它使用了48位线性同余生成器,然后返回高32位,这样避免了低位的循环问题。它的常量是A = 25,214,903,917, B = 48, 和C = 11。

public class Random48 {
        private static final long A = 25214903917L;
        private static final long B = 48;
        private static final long C = 11;
        private static final long M = (1L << B);
        private static final long MASK = M - 1;

        public Random48() {
            this(System.nanoTime());
        }

        public Random48(long initialValue) {
            state = initialValue & MASK;
        }

        public int nextInt() {
            return next(32);
        }

        public int nextInt(int N) {
            return (int) (Math.abs(nextLong()) % N);
        }

        public double nextDouble() {
            return (((long) (next(26)) << 27) + next(27)) / (double) (1L << 53);
        }
        
        public long nextLong() {
            return ((long) (next(32)) << 32) + next(32);
        }

        private int next(int bits) {
            if (bits <= 0 || bits > 32)
                throw new IllegalArgumentException();
            state = (A * state + C) & MASK;
            return (int) (state >>> (B - bits));
        }

        private long state;
}

因为M是2的幂,所以可以使用位操作计算(位移动计算M = 2B,替换模运算%)。这是因为,MASK=M-1的低48位全都是1,和MASK按位与操作不影响产生的48位结果。
next方法返回一个特定的数(最多32位),使用的高位更随机。先使用前一个state计算当前的值,然后是位移动(高位使用0,避免负数)。不用参数的nextInt方法获得323位;nextLong分两次计算获得64位;nextDouble分两次计算获得53位(代表尾数,其他11位代表指数);一个参数的nextInt使用模运算获得一个一定范围内的伪随机数。。
48位的随机数生成器(和31位生成器)适合许多应用。但是,加密或者仿真需要更大的不相关的随机数。

非均匀随机数

不是所有的程序都需要非均匀随机数。比如,课程的成绩一般不是均匀分布的,而是呈高斯分布的。均匀分布的随机生成器可以用来生成满足其他分布的生成器。
仿真的时候,一个重要的非均匀分布是Poisson分布,可以模拟罕见事件的发生次数。下面列表的情形满足泊松分布:

  • 在小区域中出现一次的概率与该区域的大小成正比
  • 在小区域中出现两次的概率与该区域大小的平方成正比,通常都小到可以被忽略
  • 在一个区域发生的k次事件和另一个区域发生的k次事件是不相干的(把两个概念乘起来,就是他们同时发生的概率)
  • 在一个区域内发生的平均值是可知的

如果发生的平均数是常量a,发生k次的概率是 ake-a/k!
泊松分布通常用于单次发生概率比较低的事件。比如,考虑一下买彩票的事情,赢得累积奖金的几率是14000000:1。如果一个人买了100张,赢的几率成了140000:1,于是第一条成立了。一个人持有两张中奖彩票的几率可以忽略不计,满足了条件二。如果另一个人买了10张票,他的获胜几率是1400000:1,他的获胜和第一个人是不相关的,所以第三个条件也满足了。假设卖了28000000张彩票,中奖几率就是2,满足波送分布。这样,购买k张彩票,中奖几率就是 2ke-2/k!
要根据期望值为a的泊松分布生成随机无符号整数,需要使用下列策略:重复生成0-1之间的均匀分布的随机数,直到小于等于e-a

        public int nextPoisson(double expectedValue) {
            double limit = -expectedValue;
            double product = Math.log(nextDouble());
            int count;
    
            for (count = 0; product > limit; count++)
                product += Math.log(nextDouble());
    
            return count;
        }

程序的逻辑是,一直加均匀分布的随机数的对数,直到小于或者等于-a。
另一个重要的非均匀分布是负指数分布,有相同的平均值和方差,用来模拟随机事件之间的时间间隔。

        public double nextNegExp(double expectedValue) {
            return -expectedValue * Math.log(nextDouble());
        }

还有很多常用的分布。一般都可以由均匀分布生成。

生成随机排列

考虑模拟纸牌游戏的问题。一共52张牌,我们从中抽取牌,不能重复。事实上,我们需要洗牌,然后迭代。洗牌应该是公平的,就是说,有52!中可能性。
这类问题叫随机排列。要生成一个1、2、……、N的随机排列,所有排列的机会是均等的。当然,排列的随机性收到伪随机发生器的限制。我们证明随机排列可以在线性时间内生成,每一项使用一个随机数。

    private static final <AnyType> void swapReferences(AnyType[] a, int index1, int index2) {
        AnyType tmp = a[index1];
        a[index1] = a[index2];
        a[index2] = tmp;
    }


    public static final void permute(Object[] a) {
        Random r = new Random();
        for (int j = 1; j < a.length; j++)
            swapReferences(a, j, r.nextInt(0, j));
    }

上面的代码,使用循环做随机洗牌。很明显,permute生成了随机序列。但是,所有的排列都是这样的吗?答案是既是也不是。从算法上看,是正确的。第一次调用,产生0或者1,所以有两种结果。第二次调用,产生0、1或者2,有三种结果。第N-1次调用,有N种结果。所以是正确的。但是,随机数生成器只有231-2个初始状态,所以只能有231-2种排列。

猜你喜欢

转载自blog.csdn.net/weixin_43364172/article/details/84765392