2023年09月22日
幾乎所有的程式設計師都寫過類似於「洗牌」的演算法,也就是將乙個陣列隨機打亂後輸出,雖然很簡單,但是深入研究起來,這個小小的演算法也是大有講究。我在面試程式設計師的時候,就會經常讓他們當場寫乙個洗牌的函式,從中可以觀察到他們對於這個問題的理解和寫程式的基本功。
在深入討論之前,必須先定義出乙個基本概念:究竟洗牌演算法的本質是什麼?也就是說,什麼樣的洗牌結果是「正確」的?
雲風曾經有一篇博文,專門討論了這個問題,他也給出了乙個比較確切的定義,在經過洗牌函式後,如果能夠保證每乙個資料出現在所有位置的概率是相等的,那麼這種演算法是符合要求的。在這個前提下,盡量降低時間複雜度和空間複雜度就能得到好的演算法。
第乙個洗牌演算法:
隨機抽出一張牌,檢查這張牌是否被抽取過,如果已經被抽取過,則重新抽取,直到找到沒被抽出過的牌,然後把這張牌放入洗好的佇列中,重複該過程,直到所有的牌被抽出。大概是比較符合大腦對於洗牌的直觀思維,這個演算法經常出現在我遇到的面試結果中,雖然它符合我們對於洗牌演算法的基本要求,但這個演算法並不好,首先它的複雜度為o(n2),而且需要額外的記憶體空間儲存已經被抽出的牌的索引。所以當資料量比較大時,會極大降低效率。
第二個演算法:
設牌的張數為n,首先準備n個不容易碰撞的隨機數,然後進行排序,通過排序可以得到乙個打亂次序的序列,按照這個序列將牌打亂。這也是乙個符合要求的演算法,但是同樣需要額外的儲存空間,在複雜度上也會取決於所採用的排序演算法,所以仍然不是乙個好的演算法。
第三個演算法:
每次隨機抽出兩張牌交換,重複交換一定次數次後結束void
shuffle
(int
* data
, int
length)}
這又是乙個常見的洗牌方法,比較有意思的問題是其中的「交換次數」,我們該如何確定乙個合適的交換次數?簡單的計算,交換m次後,具體某張牌始終沒有被抽到的概率為((n-2)/n)^m,如果我們要求這個概率小於1/1000,那麼m>-3*ln(10)/ln(1-2/n),對於52張牌,這個數大約是176次,需要注意的是,這是滿足「具體某張牌」始終沒有被抽到的概率,如果需要滿足「任意一張牌」沒被抽到的概率小於1/1000,需要的次數還要大一些,但這個概率計算起來比較複雜,有興趣的朋友可以試一下。
update: 這個概率是
第四個演算法:
從第一張牌開始,將每張牌和隨機的一張牌進行交換void
shuffle
(int
* data
, int
length)}
很明顯,這個演算法是符合我們先前的要求的,時間複雜度為o(n),而且也不需要額外的臨時空間,似乎我們找到了最優的演算法,然而事實並非如此,看下乙個演算法。
第五個演算法:
void
shuffle
(int
* data
, int
length)}
乙個有意思的情況出現了,這個演算法和第三種演算法非常相似,從直覺來說,似乎使資料「雜亂」的能力還要弱於第三種,但事實上,這種演算法要強於第三種。要想嚴格的證明這一點並不容易,需要一些數學功底,有興趣的朋友可以參照一下這篇**,或者matrix67大牛的博文,也可以這樣簡單理解一下,對於n張牌的資料,實際排列的可能情況為n! 種,但第四種演算法能夠產生n^n種排列,遠遠大於實際的排列情況,而且n^n不能被n!整除,所以經過演算法四所定義的牌與牌之間的交換程式,很可能一張牌被換來換去又被換回到原來的位置,所以這個演算法不是最優的。而演算法五輸出的可能組合恰好是n!種,所以這個演算法才是完美的。
事情並沒有結束,如果真的要找乙個最優的演算法,還是請出最終的冠軍吧!
第六個演算法:
void
shuffle
(int
* data
, int
length)
沒錯,用c++的標準庫函式才是最優方案,事實上,std::random_shuffle在實現上也是採取了第四種方法,看來還是那句話,「不要重複製造輪子」
標籤: 程式, 演算法
記得當年搞noip時,我犯過乙個相當嚴重的錯誤:錯誤地把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了。搞了半天,第一種看似對稱而美觀的演算法居然是錯的!
洗牌的學問
幾乎所有的程式設計師都寫過類似於 洗牌 的演算法,也就是將乙個陣列隨機打亂後輸出,雖然很簡單,但是深入研究起來,這個小小的演算法也是大有講究。我在面試程式設計師的時候,就會經常讓他們當場寫乙個洗牌的函式,從中可以觀察到他們對於這個問題的理解和寫程式的基本功。在深入討論之前,必須先定義出乙個基本概念 ...
完美洗牌 洗牌
完美洗牌問題,給定乙個陣列a1,a2,a3,an,b1,b2,b3.bn,把它最終設定為b1,a1,b2,a2,bn,an這樣的。o n 的演算法,o n 的空間。對於前n個數,對映為f i 2 i 1,0 i n 2 比如0 1,1 3 對於後n個數,對映為f i 2 i n 2 n 2 i n ...
怪異的洗牌
題目描述 對於一副撲克牌,我們有多種不同的洗牌方式。一種方法是從中間某個位置分成兩半,然後相交換,我們稱之為移位 shift 比如原來的次序是123456,從第4個位置交換,結果就是561234。這個方式其實就是陣列的迴圈移位,為了多次進行這個操作,必須使用一種盡可能快的方法來程式設計實現。在本題目...