計算機程序的思惟邏輯 (34) - 隨機

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html

隨機

本節,咱們來討論隨機,隨機是計算機程序中一個很是常見的需求,好比說:java

  • 各類遊戲中有大量的隨機,好比撲克遊戲洗牌
  • 微信搶紅包,搶的紅包金額是隨機的
  • 北京購車搖號,誰能搖到是隨機的
  • 給用戶生成隨機密碼

咱們首先來介紹Java中對隨機的支持,同時介紹其實現原理,而後咱們針對一些實際場景,包括洗牌、搶紅包、搖號、隨機高強度密碼、帶權重的隨機選擇等,討論如何應用隨機。算法

先來看如何使用最基本的隨機。編程

Math.random

Java中,對隨機最基本的支持是Math類中的靜態方法random,它生成一個0到1的隨機數,類型爲double,包括0但不包括1,好比,隨機生成並輸出3個數:數組

for(int i=0;i<3;i++){
    System.out.println(Math.random());
}
複製代碼

個人電腦上的一次運行,輸出爲:安全

0.4784896133823269
0.03012515628333423
0.7921024363953197
複製代碼

每次運行,輸出都不同。bash

Math.random()是如何實現的呢?咱們來看相關代碼:微信

private static Random randomNumberGenerator;

private static synchronized Random initRNG() {
    Random rnd = randomNumberGenerator;
    return (rnd == null) ? (randomNumberGenerator = new Random()) : rnd;
}

public static double random() {
    Random rnd = randomNumberGenerator;
    if (rnd == null) rnd = initRNG();
    return rnd.nextDouble();
}
複製代碼

內部它使用了一個Random類型的靜態變量randomNumberGenerator,調用random()就是調用該變量的nextDouble()方法,這個Random變量只有在第一次使用的時候才建立。多線程

下面咱們來看這個Random類,它位於包java.util下。併發

Random

基本用法

Random類提供了更爲豐富的隨機方法,它的方法不是靜態方法,使用Random,先要建立一個Random實例,看個例子:

Random rnd = new Random();
System.out.println(rnd.nextInt());
System.out.println(rnd.nextInt(100));
複製代碼

個人電腦上的一次運行,輸出爲:

-1516612608
23
複製代碼

nextInt()產生一個隨機的int,可能爲正數,也可能爲負數,nextInt(100)產生一個隨機int,範圍是0到100,包括0不包括100。

除了nextInt,還有一些別的方法。

隨機生成一個long

public long nextLong() 複製代碼

隨機生成一個boolean

public boolean nextBoolean() 複製代碼

產生隨機字節

public void nextBytes(byte[] bytes) 複製代碼

隨機產生的字節放入提供的byte數組bytes,字節個數就是bytes的長度。

產生隨機浮點數,從0到1,包括0不包括1

public float nextFloat() public double nextDouble() 複製代碼

設置種子

除了默認構造方法,Random類還有一個構造方法,能夠接受一個long類型的種子參數:

public Random(long seed) 複製代碼

種子決定了隨機產生的序列,種子相同,產生的隨機數序列就是相同的。看個例子:

Random rnd = new Random(20160824);
for(int i=0;i<5;i++){
    System.out.print(rnd.nextInt(100)+" ");
}
複製代碼

種子爲20160824,產生5個0到100的隨機數,輸出爲:

69 13 13 94 50 
複製代碼

這個程序不管執行多少遍,在哪執行,輸出結果都是相同的。

除了在構造方法中指定種子,Random類還有一個setter實例方法:

synchronized public void setSeed(long seed) 複製代碼

其效果與在構造方法中指定種子是同樣的。

爲何要指定種子呢?指定種子仍是真正的隨機嗎?

指定種子是爲了實現可重複的隨機。好比用於模擬測試程序中,模擬要求隨機,但測試要求可重複。在北京購車搖號程序中,種子也是指定的,後面咱們還會介紹。

種子到底扮演了什麼角色呢?隨機究竟是如何產生的呢?讓咱們看下隨機的基本原理。

隨機的基本原理

Random產生的隨機數不是真正的隨機數,相反,它產生的隨機數通常稱之爲僞隨機數,真正的隨機數比較難以產生,計算機程序中的隨機數通常都是僞隨機數。

僞隨機數都是基於一個種子數的,而後每須要一個隨機數,都是對當前種子進行一些數學運算,獲得一個數,基於這個數獲得須要的隨機數和新的種子。

數學運算是固定的,因此種子肯定後,產生的隨機數序列就是肯定的,肯定的數字序列固然不是真正的隨機數,但種子不一樣,序列就不一樣,每一個序列中數字的分佈也都是比較隨機和均勻的,因此稱之爲僞隨機數。

Random的默認構造方法中沒有傳遞種子,它會自動生成一個種子,這個種子數是一個真正的隨機數,代碼以下:

private static final AtomicLong seedUniquifier
    = new AtomicLong(8682522807148012L);
    
public Random() {
    this(seedUniquifier() ^ System.nanoTime());
}

private static long seedUniquifier() {
    for (;;) {
        long current = seedUniquifier.get();
        long next = current * 181783497276652981L;
        if (seedUniquifier.compareAndSet(current, next))
            return next;
    }
}
複製代碼

種子是seedUniquifier() 與System.nanoTime()按位異或的結果,System.nanoTime()返回一個更高精度(納秒)的當前時間,seedUniquifier()裏面的代碼涉及一些多線程相關的知識,咱們後續章節再介紹,簡單的說,就是返回當前seedUniquifier(current)與一個常數181783497276652981L相乘的結果(next),而後,將seedUniquifier設置爲next,使用循環和compareAndSet都是爲了確保在多線程的環境下不會有兩次調用返回相同的值,保證隨機性。

有了種子數以後,其餘數是怎麼生成的呢?咱們來看一些代碼:

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

public long nextLong() {
    return ((long)(next(32)) << 32) + next(32);
}

public float nextFloat() {
    return next(24) / ((float)(1 << 24));
}
public boolean nextBoolean() {
    return next(1) != 0;
}
複製代碼

它們都調用了next(int bits),生成指定位數的隨機數,咱們來看下它的代碼:

private static final long multiplier = 0x5DEECE66DL;
private static final long addend = 0xBL;
private static final long mask = (1L << 48) - 1;
protected int next(int bits) {
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
        oldseed = seed.get();
        nextseed = (oldseed * multiplier + addend) & mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int)(nextseed >>> (48 - bits));
}
複製代碼

簡單的說,就是使用了以下公式:

nextseed = (oldseed * multiplier + addend) & mask;
複製代碼

舊的種子(oldseed)乘以一個數(multiplier),加上一個數addend,而後取低48位做爲結果(mask相與)。

爲何採用這個方法?這個方法爲何能夠產生隨機數?這個方法的名稱叫線性同餘隨機數生成器(linear congruential pseudorandom number generator),描述在《計算機程序設計藝術》一書中。隨機的理論是一個比較複雜的話題,超出了本文的範疇,咱們就不討論了。

咱們須要知道的基本原理是,隨機數基於一個種子,種子固定,隨機數序列就固定,默認構造方法中,種子是一個真正的隨機數。

理解了隨機的基本概念和原理,咱們來看一些應用場景,從產生隨機密碼開始。

隨機密碼

在給用戶生成帳號時,常常須要給用戶生成一個默認隨機密碼,而後經過郵件或短信發給用戶,做爲初次登陸使用。

咱們假定密碼是6位數字,代碼很簡單,以下所示:

public static String randomPassword(){
    char[] chars = new char[6];
    Random rnd = new Random();
    for(int i=0; i<6; i++){
        chars[i] = (char)('0'+rnd.nextInt(10));
    }
    return new String(chars);
}
複製代碼

代碼很簡單,就不解釋了。若是要求是8位密碼,字符可能有大寫字母、小寫字母、數字和特殊符號組成,代碼可能爲:

private static final String SPECIAL_CHARS = "!@#$%^&*_=+-/";

private static char nextChar(Random rnd){
    switch(rnd.nextInt(4)){
    case 0:
        return (char)('a'+rnd.nextInt(26));
    case 1:
        return (char)('A'+rnd.nextInt(26));
    case 2:
        return    (char)('0'+rnd.nextInt(10));
    default:
        return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
    }
}

public static String randomPassword(){
    char[] chars = new char[8];
    Random rnd = new Random();
    for(int i=0; i<8; i++){
        chars[i] = nextChar(rnd);
    }
    return new String(chars);
}
複製代碼

這個代碼,對每一個字符,先隨機選類型,而後在給定類型中隨機選字符。在個人電腦上,一次的隨機運行結果是:

8Ctp2S4H
複製代碼

這個結果不含特殊字符,不少環境對密碼複雜度有要求,好比說,至少要含一個大寫字母、一個小寫字母、一個特殊符號、一個數字。以上的代碼知足不了這個要求,怎麼知足呢?一種可能的代碼是:

private static int nextIndex(char[] chars, Random rnd){
     int index = rnd.nextInt(chars.length);
     while(chars[index]!=0){
        index = rnd.nextInt(chars.length);
     }
     return index;
}

private static char nextSpecialChar(Random rnd){
    return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
private static char nextUpperlLetter(Random rnd){
    return (char)('A'+rnd.nextInt(26));
}
private static char nextLowerLetter(Random rnd){
    return (char)('a'+rnd.nextInt(26));
}
private static char nextNumLetter(Random rnd){
    return (char)('0'+rnd.nextInt(10));
}
public static String randomPassword(){
    char[] chars = new char[8];
    Random rnd = new Random();
    
    chars[nextIndex(chars, rnd)] = nextSpecialChar(rnd);
    chars[nextIndex(chars, rnd)] = nextUpperlLetter(rnd);
    chars[nextIndex(chars, rnd)] = nextLowerLetter(rnd);
    chars[nextIndex(chars, rnd)] = nextNumLetter(rnd);
    
    for(int i=0; i<8; i++){
        if(chars[i]==0){
            chars[i] = nextChar(rnd);    
        }
    }
    return new String(chars);
}
複製代碼

nextIndex隨機生成一個未賦值的位置,程序先隨機生成四個不一樣類型的字符,放到隨機位置上,而後給未賦值的其餘位置隨機生成字符。

洗牌

一種常見的隨機場景是洗牌,就是將一個數組或序列隨機從新排列,咱們以一個整數數組爲例來看,怎麼隨機重排呢?咱們直接看代碼:

private static void swap(int[] arr, int i, int j){
    int tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
}

public static void shuffle(int[] arr){
    Random rnd = new Random();
    for(int i=arr.length; i>1; i--) {
        swap(arr, i-1, rnd.nextInt(i));
    }
}
複製代碼

shuffle這個方法就能將參數數組arr隨機重排,來看使用它的代碼:

int[] arr = new int[13];
for(int i=0; i<arr.length; i++){
    arr[i] = i;
}
shuffle(arr);
System.out.println(Arrays.toString(arr));
複製代碼

調用shuffle前,arr是排好序的,調用後,一次調用的輸出爲:

[3, 8, 11, 10, 7, 9, 4, 1, 6, 12, 5, 0, 2]
複製代碼

已經隨機從新排序了。

shuffle的基本思路是什麼呢?從後往前,逐個給每一個數組位置從新賦值,值是從剩下的元素中隨機挑選的。在以下關鍵語句中,

swap(arr, i-1, rnd.nextInt(i));
複製代碼

i-1表示當前要賦值的位置,rnd.nextInt(i)表示從剩下的元素中隨機挑選。

帶權重的隨機選擇

實際場景中,常常要從多個選項中隨機選擇一個,不過,不一樣選項常常有不一樣的權重。

好比說,給用戶隨機獎勵,三種面額,1元、5元和10元,權重分別爲70, 20和10。這個怎麼實現呢?

實現的基本思路是,使用機率中的累計機率分佈。

以上面的例子來講,計算每一個選項的累計機率值,首先計算總的權重,這裏正好是100,每一個選項的機率是70%,20%和10%,累計機率則分別是70%,90%和100%。

有了累計機率,則隨機選擇的過程是,使用nextDouble()生成一個0到1的隨機數,而後使用二分查找,看其落入那個區間,若是小於等於70%則選擇第一個選項,70%和90%之間選第二個,90%以上選第三個,以下圖示所示:

下面來看代碼,咱們使用一個類Pair表示選項和權重,代碼爲:

class Pair {
    Object item;
    int weight;
    
    public Pair(Object item, int weight){
        this.item = item;
        this.weight = weight;
    }

    public Object getItem() {
        return item;
    }

    public int getWeight() {
        return weight;
    }
}
複製代碼

咱們使用一個類WeightRandom表示帶權重的選擇,代碼爲:

public class WeightRandom {
    private Pair[] options;
    private double[] cumulativeProbabilities;
    private Random rnd;
    
    public WeightRandom(Pair[] options){
        this.options = options;
        this.rnd = new Random();
        prepare();
    }
    
    private void prepare(){
        int weights = 0;
        for(Pair pair : options){
            weights += pair.getWeight();
        }
        cumulativeProbabilities = new double[options.length];
        int sum = 0;
        for (int i = 0; i<options.length; i++) {
            sum += options[i].getWeight();
            cumulativeProbabilities[i] = sum / (double)weights;
        }
    }
    
    public Object nextItem(){
        double randomValue = rnd.nextDouble();

        int index = Arrays.binarySearch(cumulativeProbabilities, randomValue);
        if (index < 0) {
            index = -index-1;
        }
        return options[index].getItem();
    }
}
複製代碼

其中,prepare方法計算每一個選項的累計機率,保存在數組cumulativeProbabilities中,nextItem()根據權重隨機選擇一個,具體就是,首先生成一個0到1的數,而後使用二分查找,之前介紹過,若是沒找到,返回結果是-(插入點)-1,因此-index-1就是插入點,插入點的位置就對應選項的索引。

回到上面的例子,隨機選擇10次,代碼爲:

Pair[] options = new Pair[]{
        new Pair("1元",7),
        new Pair("2元", 2),
        new Pair("10元", 1)
};
WeightRandom rnd = new WeightRandom(options);
for(int i=0; i<10; i++){
    System.out.print(rnd.nextItem()+" ");
}
複製代碼

在一次運行中,輸出正好符合預期,具體爲:

1元 1元 1元 2元 1元 10元 1元 2元 1元 1元 
複製代碼

不過,須要說明的,因爲隨機,每次執行結果比例不必定正好相等。

搶紅包算法

咱們都知道,微信能夠搶紅包,紅包有一個總金額和總數量,領的時候隨機分配金額,金額是怎麼隨機分配的呢?微信具體是怎麼作的,咱們並不能確切的知道,根據一些公開資料,思路可能以下。

維護一個剩餘總金額和總數量,分配時,若是數量等於1,直接返回總金額,若是大於1,則計算平均值,並設定隨機最大值爲平均值的兩倍,而後取一個隨機值,若是隨機值小於0.01,則爲0.01,這個隨機值就是下一個的紅包金額。

咱們來看代碼,爲計算方便,金額咱們用整數表示,以分爲單位。

public class RandomRedPacket {

    private int leftMoney;
    private int leftNum;
    private Random rnd;
    
    public RandomRedPacket(int total, int num){
        this.leftMoney = total;
        this.leftNum = num;
        this.rnd = new Random();
    }
    
    public synchronized int nextMoney(){
        if(this.leftNum<=0){
            throw new IllegalStateException("搶光了");
        }
        if(this.leftNum==1){
            return this.leftMoney;
        }
        double max = this.leftMoney/this.leftNum*2d;
        int money = (int)(rnd.nextDouble()*max);
        money = Math.max(1, money);
        this.leftMoney -= money;
        this.leftNum --;
        
        return money;
    }
}
複製代碼

代碼比較簡單,就不解釋了。咱們來看一個使用的例子,總金額爲10元,10個紅包,代碼以下:

RandomRedPacket redPacket = new RandomRedPacket(1000, 10);
for(int i=0; i<10; i++){
    System.out.print(redPacket.nextMoney()+" ");
}
複製代碼

一次輸出爲:

136 48 90 151 36 178 92 18 122 129 
複製代碼

若是是這個算法,那先搶好,仍是後搶好呢?先搶確定搶不到特別大的,不過,後搶也不必定會,這要看前面搶的金額,剩下的多就有可能搶到大的,剩下的少就不可能有大的。

北京購車搖號算法

咱們來看下影響不少人的北京購車搖號,它的算法是怎樣的呢?根據公開資料,它的算法大概是這樣的。

  1. 每期搖號前,將每一個符合搖號資格的人,分配一個從0到總數的編號,這個編號是公開的,好比總人數爲2304567,則編號從0到2304566。
  2. 搖號第一步是生成一個隨機種子數,這個隨機種子數在搖號當天經過必定流程生成,整個過程由公證員公證,就是生成一個真正的隨機數。
  3. 種子數生成後,而後就是循環調用相似Random.nextInt(int n)方法,生成中籤的編號。

編號是事先肯定的,種子數是當場公證隨機生成的,公開的,隨機算法是公開透明的,任何人均可以根據公開的種子數和編號驗證中籤的編號。

一些說明

須要說明的是,Random類是線程安全的,也就是說,多個線程能夠同時使用一個Random實例對象,不過,若是併發性很高,會產生競爭,這時,能夠考慮使用多線程庫中的ThreadLocalRandom類。

另外,Java類庫中還有一個隨機類SecureRandom,以產生安全性更高、隨機性更強的隨機數,用於安全加密等領域。

這兩個類本文就不介紹了。

小結

本節介紹了隨機,介紹了Java中對隨機的支持Math.random()以及Random類,介紹了其使用和實現原理,同時,咱們介紹了隨機的一些應用場景,包括隨機密碼、洗牌、帶權重的隨機選擇、微信搶紅包和北京購車搖號。

至此,關於一些基本經常使用類的介紹,咱們就告一段落了,回顧一下,咱們深刻剖析了各類包裝類、String、StringBuilder、Arrays、日期和時間、Joda-Time以及隨機,這些都是平常程序中常常用到的功能。

以前章節中,咱們常常提到泛型這一律念,是時候具體討論一下了。


未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索