再锻炼,有新发现 Math.random() * n vs Random.nextInt(): 这不是Bug?

来源:互联网 发布:魔方还原软件下载 编辑:程序博客网 时间:2024/06/06 05:58

工作当中并不常用到随机数,偶尔需要用,总是从Math类和Random类当中随便挑一个,从来没有想过其中的区别。今天心血来潮google一番,发现有人称Random的效率高过Math(http://stackoverflow.com/questions/738629/math-random-versus-random-nextintint),特此做了点研究。(研究完了才开始后悔,本来10秒钟写一行代码就over的事情耗掉了我整个下午)

首先是参考资料:(Java API 文档以及JDK 源代码)

//Math.random: 调用Random类的nextDouble()方法
//Random类中nextDouble的实现
public double nextDouble() {
    return (((long)next(26) << 27) + next(27)) / (double)(1L << 53);
}
//Random类中nextInt的实现
public int nextInt(int n) {
    if (n<=0)
        throw new IllegalArgumentException("n must be positive");
    if ((n & -n) == n)  // i.e., n is a power of 2
        return (int)((n * (long)next(31)) >> 31);
    int bits, val;
    do {
        bits = next(31);
        val = bits % n;
    } while(bits - val + (n-1) < 0);
    return val;
}

可以看到Math调用了两次Random的next(n)方法,而Random的nextInt则有些复杂,在n是2的整数幂时调用1次,否则在极端条件下则平均调用两次next(n),整体的平均调用大约比1多一点点。所以nextInt的效率据称平均要高出40%左右。以下的代码 RandomTest.java 得出略有不同的结论:(网上看到有人用类似的代码得出相反的结论,但他们的代码都只调用一次而不是像我这样调用了一千万次,再加上nanoTime()这个函数是典型的有精度没准度……,我的代码只在上网本和PC上测试过,一个是intel32 + win7,一个是amd64 + Fedora 13 x64,jdk都是6u21)

import java.util.Random;
public class RandomTest {
    long last = 0L;
    R rt = new R();
    Random random = new Random();
    public static void main(String[] args) {
        RandomTest t = new RandomTest();
        t.test(10000000, 1024);
        t.test(10000000, (int)Math.pow(2, 30) + 1);
    }
    void test(int repeats, int n) {
        System.out.println("n = " + n + ", repeat " + repeats + "times");
        getTime();
        for(int i = 0; i < repeats; i++) {
            r1(n);
        }
        System.out.println(getTime());
        for(int i = 0; i < repeats; i++) {
            r2(n);
        }
        System.out.println(getTime());
        for(int i = 0; i < repeats; i++) {
            r3(n);
        }
        System.out.println(getTime());
    }
    int getTime() {
        long now = System.nanoTime();
        int time = (int)((now - last) / 1000000);
        last = now;
        return time;
    }
    int r1(int n) {
        return (int)(Math.random() * n);
    }
    int r2(int n) {
        return random.nextInt(n);
    }
    int r3(int n) {
        return rt.ni(n);
    }
}
class R extends Random {
    int ni(int n) {
        return (int)((n * (long)next(31)) >> 31);
    }
}

测试结果如下(单位是毫秒,数值每次都有小变动,但大致如此)

n = 1024, repeat 10000000times
195
111
111
n = 1073741825, repeat 10000000times
171
547
107

这里测试了3种不同的策略,第一种是Math,第二种是Random,第三种是调用Random的protected方法做的近似的随机数方法(我在实际应用的时候n很小,我也不在乎那微乎其微的分布偏差,那么为什么修改下nextInt函数去掉纠偏逻辑呢,这就是我写出RT这个类的原因。注意:在Groovy中可以直接调用这个protected函数,所以不需要多写一个类)。可以看到方法一和方法三的运行时间基本保持恒定,而方法二在不同的n值下效率发生了5倍的变化(原先我预估是3倍左右),这是因为当n不是2的整数幂时,nextInt会对next(31)返回的值作出筛选,从而达到值的平均分布。判断的语句就是 while (bits - val + (n-1) < 0),这句语句实在让人困惑。

现在来看nextInt的源代码,并让我们来做一点小学数学。

因为:bits = next(31)

并且:next(int bits) @return the next pseudorandom value from this random number generator's sequence (意思就是 next(int bits) 返回一个介于0和2^bits - 1之间的值)

所以:0 >= bits > 2^31

因为:%的定义是 a%b = a – a/b * b,(这里的除法是整数除法)

并且:非负整数/正整数 >= 0

所以:存在非负整数 x 满足 a%b = a – x * b

又因为:val = bits % n

所以:存在非负整数 x 满足 val = bits – x * n

结论1:bits – val + (n – 1) = bits - (bits – x * n) + n – 1 = x * n + n – 1 = (x + 1) * n – 1

因为:x >= 0 推出 x + 1 > 0

并且:n > 0

所以:(x + 1) * n >= 1

结论:bits - val + (n-1) < 0 永不成立。

My God,我发现了JDK里的BUG?!!这是真的吗?

好有成就感

“正确的”表达式似乎应该是 bits – val - (n – 1) < 0,如此,则简化的表达式为 (x – 1) * n + 1,也就是当 x = 0 时(或者说 bits <= 2 * n)时,条件成立。

于是我登录bugs.sun.com,开始我的第一次JDK除虫之旅(为此被迫忍受了龟速网络,还被迫写了一大段的鸟语)。在按下submit按钮前,我做了最后一次确认:这次,我用的n是1024和1025:

n = 1024, repeat 10000000times
192
112
112
n = 1025, repeat 10000000times
172
268
107

这一次,方案二的效率差别是2倍多一点。也就是说:while的条件一定成立过,否则不同的n应该消耗近似的时间!哪里出了错?

首先可以肯定的是我的确念完了小学(我的数学老师大概也认同这点,anyway),而我的推理也毫无疑问是正确的;但计算机执行的结果似乎说明源代码的确按照设计的目标运行了。怎么办?

这里要用点鉴证科学来对现场进行勘察。在RT类中添加如下代码:(CSI背景音乐响起……)

void csi() {
    int n = (int)Math.pow(2, 30) + 1;
    int yes = 0;
    int no = 0;
    for(int i = 0; i < 10; i++) {
        int bits = next(31);
        int val = bits % n;
        if(bits - val + (n - 1) < 0) {
            System.out.println("Yes");
            yes++;
        } else {
            System.out.println("No");
            no++;
        }
 
        System.out.println("bits = " + bits);
        System.out.println("val = " + val);
        System.out.println("bits - val = " + (bits - val));
        System.out.println("Result = " + (bits - val + (n - 1)));
    }
    System.out.println("Total yes: " + yes);
    System.out.println("Total no: " + no);
}

执行csi方法,线索出现:1. 当 n = 2^30 + 1 的时候,条件成立的概率和文档里说的是一样的——五五开;2. 第二个小学数学题出现了,从输出中选择一个结果:

Yes
bits = 1937150834
val = 863409009
bits - val = 1073741825
Result = -2147483647

1937150834 – 863409009 + ((2^30 + 1) – 1) = ?

2147483649?

恭喜你,答错了。想一想int的取值范围!int的最大值应该是2147483647 (也就是2^31 – 1)。

哦,整数溢出了,实际得到的值是 –2147483647。

也就是说,实际上 while 判断的表达式不是int表达式 (x + 1) * n – 1 < 0,而是数学表达式 bits – val – 1 + n >= 2^31。这个表达式意味着 while 循环会从 2^31 – 1 开始,递减地剔除 val 个数,Why?Capture

由:bits – val – 1 + n >= 2^31

可知:bits - 1 >= 2^31 - (n – val)

可知:bits > 2^31 - (n – val)

在上面的图片里查看一下2^31 – (n – val) 是哪一个范围?答案是val + n * max,其最小值为n * max,正好超标。

Briliant! 我实在不晓得原作者是怎么写出这行代码的,但有两个结论几乎是必然的,一是这个老兄(姐)太天才,二是他(她)实在吃饱了没事——明显有一个更加直白的表达式。

首先我们先计算一个边界值,引入一个整数 max,假设 max * n <= 2^31 < max * (n + 1),则有:

max * n = 2^31 - 2^31%n

那么bits的最大值是多少?对了,是 max * n – 1 = 2^31 - 2^31%n – 1

所以如果让我来写nextInt,会是下面这个样子,没有整数溢出问题,清晰明了。

public int nextInt(int n) {
    if(n <= 0)
        throw new IllegalArgumentException("n must be positive");
    if ((n & -n) == n)  // i.e., n is a power of 2
        return (int)((n * (long)next(31)) >> 31);
    int bit;
    while ((bits = next(31)) > maxBits(n)) {}
    return bit % n;
}
int maxBits(int n) {
    return Integer.MAX_VALUE - (Integer.MAX_VALUE % n + 1) % n; 
}

不过,这也付出了代价:必须调用一个函数或是用一个巨长的表达式来进行判断,这个效率……即使把函数优化成inline,然后对maxBits()做缓存,效率也是刚刚赶上原版的代码——所以人家不是吃饱了没事情干,真的是有本事啊。

OK,结案时间。

  • 当需要生成整型的伪随机数时,有三种方法可以选择,Random.nextInt(n)是最正统的、随机分布最均匀的方法,而且在n为2的整数幂的情况下,也是最快的方法(同方法三),但对于某些n值而言,需要重复调用next(),因此在特殊条件下可能是最慢的。Math方法的分布比较均匀(毕竟double不是数学意义上的实数,它不是连续分布的,所以转换成int会有误差),速度也较稳定,但有时比nextInt慢(规律不明,但纳秒级的差别,谁在乎)。用next() DIY的方法则在任何时候都是最快的,但在特殊情况下分布最不均匀,即便如此,其不均匀的程度也几乎是无法察觉的(当n = 1000时,偏差率的数量级是10^-7)
  • 速度通常不是考虑的重点,尤其当每秒能计算一亿次的情况下(写到这里突然意识到原来可以用穷举法验证这里的算法,也就一分多钟的运行时间,写成多线程的话10秒搞定……)。
  • 在这个案例里,如果随机数的产生有速度的要求,那么可以确定的是:这个函数被调用了“非常非常多”的次数,这种情况下,几乎一定要考虑数值的分布状态,也就是说,即使nextInt是最慢的,也一定要使用它。(当有速度需求的时候反而选择最慢的函数,这种情况实在罕见)
  • JDK的代码是正确的,还好没有submit issue。
  • 写明白的代码还是写“高效”的代码,视情况而言,但无论如何,nextInt都是一个很好的学习范本。
  • 6块钱一杯的半价冰拿铁比自己的速溶摩卡要提神多了,不过Bread Studio的面包还是有些小贵,BS…
  • 虽然是半价,但因为是拿铁所以咖啡还不到半杯,结果写到一半就断货了,所以后半篇如果发现很多BUGs,请宽恕我吧。
原创粉丝点击