1. 程式人生 > >程式設計常用演算法分析——洗牌演算法

程式設計常用演算法分析——洗牌演算法

首先我們需要明白幾個數學思想——————————————

一、 我們的洗牌演算法是偽隨機事件的一種。而隨機事件我們也要明白幾個概念

  隨機數的特性:

隨機性:不存在統計學偏差,是完全雜亂的數列

不可預測性:不能從過去的數列推測出下一個出現的數

不可重現性:除非將數列本身儲存下來,否則不能重現相同的數列

 

隨機數分為真隨機數和偽隨機數,我們程式使用的基本都是偽隨機數,其中偽隨機又分為強偽隨機數和弱偽隨機數。

  • 真隨機數,通過物理實驗得出,比如擲錢幣、骰子、轉輪、使用電子元件的噪音、核裂變等。需要滿足隨機性、不可預測性、不可重現性。

  • 偽隨機數,通過一定演算法和種子得出。軟體實現的是偽隨機數。

    • 強偽隨機數,難以預測的隨機數。需要滿足隨機性和不可預測性。

    • 弱偽隨機數,易於預測的隨機數。需要滿足隨機性

結合一下程式設計:Java中的隨機數生成器

Java語言提供了幾種隨機數生成器,如前面提到的Random類,還有SecureRandom類。

偽隨機數生成器

偽隨機數發生器採用特定的演算法,將隨機數種子seed轉換成一系列的偽隨機數。偽隨機數依賴於seed的值,給定相同的seed值總是生成相同的隨機數。偽隨機數的生成過程只依賴CPU,不依賴任何外部裝置,生成速度快,不會阻塞。

Java提供的偽隨機數發生器有java.util.Random類和java.util.concurrent.ThreadLocalRandom類。

Random類採用AtomicLong實現,保證多執行緒的執行緒安全性,但正如該類註釋上說明的,多執行緒併發獲取隨機數時效能較差。

多執行緒環境中可以使用ThreadLocalRandom作為隨機數發生器,ThreadLocalRandom採用了執行緒區域性變數來改善效能,這樣就可以使用long而不是AtomicLong,此外,ThreadLocalRandom還進行了位元組填充,以避免偽共享。

強隨機數發生器

強隨機數發生器依賴於作業系統底層提供的隨機事件。強隨機數生成器的初始化速度和生成速度都較慢,而且由於需要一定的熵累積才能生成足夠強度的隨機數,所以可能會造成阻塞。熵累積通常來源於多個隨機事件源,如敲擊鍵盤的時間間隔,移動滑鼠的距離與間隔,特定中斷的時間間隔等。所以,只有在需要生成加密性強的隨機資料的時候才用它。

Java提供的強隨機數發生器是java.security.SecureRandom類,該類也是一個執行緒安全類,使用synchronize方法保證執行緒安全,但jdk並沒有做出承諾在將來改變SecureRandom的執行緒安全性。因此,同Random一樣,在高併發的多執行緒環境中可能會有效能問題。

在linux的實現中,可以使用/dev/random/dev/urandom作為隨機事件源。由於/dev/random是堵塞的,在讀取隨機數的時候,當熵池值為空的時候會堵塞影響效能,尤其是系統大併發的生成隨機數的時候。

真隨機數發生器

在Linux系統中,SecureRandom的實現藉助了/dev/random/dev/urandom,可以使用硬體噪音生成隨機數;

http://random.org/,從1998年開始提供線上真隨機數服務了,它用大氣噪音生成真隨機數。他也提供了Java工具類,可以拿來使用。地址:https://sourceforge.net/projects/randomjapi/

 

現在我們一起來討論一下關於shuffle

時間複雜度:時間複雜度簡單的理解就是執行語句的條數。如果有迴圈和遞迴,則忽略簡單語句,直接算迴圈和遞迴的語句執行次數

時間複雜度為l

int n = 8, count = 0;;  
for(int i=1; i<=n; i *= 2) {  
    count++;  
} 

空間複雜度:空間複雜度也很簡單的理解為臨時變數佔用的儲存空間.

第一個演算法:

隨機抽出一張牌,檢查這種牌是否被抽取過,如果已經被抽取過,則重新抽取,知道找到沒有被抽取的牌;重複該過程,知道所有的牌都被抽取到。

這種演算法是比較符合大腦的直觀思維,這種演算法有兩種形式:

1. 每次隨機抽取後,將抽取的牌拿出來,則此時剩餘的牌為(N-1),這種演算法避免了重複抽取,但是每次抽取一張牌後,都有一個刪除操作,需要在原始陣列中刪除隨機選中的牌(可使用Hashtable實現)

function Shuffle (){
	let Arr = [];
     for(var i = 1; i <=52; i++){
        Arr.push(i)
    }
	var newArr = [];
	for (var i = 1; i <=52; i++) {
   		var random = Math.floor((Math.random()*Arr.length-1+1));
   		console.log(random)
   		newArr.push(Arr[random]);
   		Arr.splice(random,1);
	}
    console.log(newArr)
}
Shuffle ();

2. 每次隨機抽取後,將抽取的符合要求的牌做好標記,但並不刪除;與1相比,省去了刪除的操作,但增加了而外的儲存標誌為的空間,同時導致可每次可能會抽取之前抽過的牌

這種方法的時間/空間複雜度都不好。

function Shuffle (){
    let Arr = [];
     for(var i = 1; i <=52; i++){
        Arr.push(i)
    }
    var newArr = [];
    for (var i = 1; i <=52; i++) {
           var random = Math.floor((Math.random()*Arr.length-1+1));
           console.log(random)
           newArr.push(Arr[random]);
           Arr.splice(random,1);
    }
    console.log(newArr)
}
Shuffle ();

第二個演算法:

每次隨機抽出兩張牌交換,交換一定次數後結束:

void shuffle(int* array, int len){  
  const int suff_time = len;	    
  for (int idx = 0; i < suff_time; i++)	{	
  int i = rand() % len;		
  int j = rand() % len;				
  int temp = array[i];		
  array[i] = array[j];		array[j] = temp;	
}}

3、該演算法每次隨機選取一個數,然後將該數與陣列中最後(或最前)的元素相交換(如果隨機選中的是最後/最前的元素,則相當於沒有發生交換);然後縮小選取陣列的範圍,去掉最後的元素,即之前隨機抽取出的數。重複上面的過程,直到剩餘陣列的大小為1,即只有一個元素時結束

void shuffle(int* array, int len)

{

    int i = len;

    int j = 0;

    int temp= = 0;

    if (i == 0)

{

return;

}

 

while (--i)

{

j = rand() % (i+1);

temp = array[i];

array[i] = array[j];

array[j] = temp

 犯過一個相當嚴重的錯誤:錯誤地把Floyd演算法的i, j, k三層迴圈的位置順序搞顛倒了。直到準備省選時我才突然意識到,Floyd演算法應該最先列舉用於鬆馳操作的那個“中間變數”k,表示只經過從1到k的頂點的最短路;而我卻一直習慣性地以為i, j, k應該順次列舉。令人驚訝的是,這個錯誤跟了我那麼久我居然從來都沒有注意到過。後來,我發現有我這種經歷的人不止一個。慣性思維很可能會讓你接受一些明顯錯誤的演算法,並且讓你用得坦坦蕩蕩,一輩子也發覺不了。
    假使你需要把一個數組隨機打亂順序進行重排。你需要保證重排後的結果是概率均等、完全隨機的。下面兩種演算法哪一種是正確的?其中,random(a,b)函式用於返回一個從a到b(包括a和b)的隨機整數。

1. for i:=1 to n do swap(a[i], a[random(1,n)]);
2. for i:=1 to n do swap(a[i], a[random(i,n)]);


    如果不仔細思考的話,絕大多數人會認為第一個演算法才是真正隨機的,因為它的操作“更對稱”,保證了概率均等。但靜下心來仔細思考,你會發現第二種演算法才是真正滿足隨機性的。為了證明這一點,只需要注意到演算法的本質是“隨機確定a[1]的值,然後遞迴地對後n-1位進行操作”,用數學歸納法即可輕易說明演算法的正確性。而事實上,這段程式一共將會產生n*(n-1)*(n-2)*…*1種等可能的情況,它們正好與1至n的n!種排列一一對應。
     有人會問,那第一種演算法為什麼就錯了呢?看它的樣子多麼對稱美觀啊……且慢,我還沒說第一種演算法是錯的哦!雖然第一種演算法將產生比第二種演算法更多的可能性,會導致一些重複的數列,但完全有可能每種數列重複了相同的次數,概率仍然是均等的。事實上,更有可能發生的是,這兩種演算法都是正確的,不過相比之下呢第一種演算法顯得更加對稱美觀一些。為此,我們需要說明,第一種演算法產生的所有情況均等地分成了n!個等價的結果。顯然,這個演算法將會產生n^n種情況,而我們的排列一共有n!個,因此n^n必須能夠被n!整除才行(否則就不能均等地分佈了)。但是,n!裡含有所有不超過n的質數,而n^n裡卻只有n的那幾個質因子。這表明要想n^n能被n!整除,n的質因子中必須含有所有不超過n的質數。這個結論看上去相當荒唐,反例遍地都是,並且直覺上告訴我們對於所有大於2的n這都是不成立的。為了證明這一點,只需要注意到2是質數,並且根據Bertrand-Chebyshev定理,在n/2和n之間一定還有一個質數。這兩個質數的乘積已經大於n了。搞了半天,第一種看似對稱而美觀的演算法居然是錯的!