數據結構和算法面試題系列—隨機算法總結

這個系列是我多年前找工做時對數據結構和算法總結,其中有基礎部分,也有各大公司的經典的面試題,最先發布在CSDN。現整理爲一個系列給須要的朋友參考,若有錯誤,歡迎指正。本系列完整代碼地址在 這裏git

0 概述

隨機算法涉及大量機率論知識,有時候可貴去仔細看推導過程,固然可以徹底瞭解推導的過程天然是有好處的,若是不瞭解推導過程,至少記住結論也是必要的。本文總結最多見的一些隨機算法的題目,是幾年前找工做的時候寫的。須要說明的是,這裏用到的隨機函數 randInt(a, b) 假定它能隨機的產生範圍 [a,b] 內的整數,即產生每一個整數的機率相等(雖然在實際中並不必定能實現,不過不要太在乎,這個世界不少事情都很隨機)。本文代碼在 這裏github

1 隨機排列數組

假設給定一個數組 A,它包含元素 1 到 N,咱們的目標是構造這個數組的一個均勻隨機排列。面試

一個經常使用的方法是爲數組每一個元素 A[i] 賦一個隨機的優先級 P[i],而後依據優先級對數組進行排序。好比咱們的數組爲 A = {1, 2, 3, 4},若是選擇的優先級數組爲 P = {36, 3, 97, 19},那麼就能夠獲得數列 B={2, 4, 1, 3},由於 3 的優先級最高(爲97),而 2 的優先級最低(爲3)。這個算法須要產生優先級數組,還需使用優先級數組對原數組排序,這裏就不詳細描述了,還有一種更好的方法能夠獲得隨機排列數組。算法

產生隨機排列數組的一個更好的方法是原地排列(in-place)給定數組,能夠在 O(N) 的時間內完成。僞代碼以下:編程

RANDOMIZE-IN-PLACE ( A , n ) 
	for i ←1 to n do 
		swap A[i] ↔ A[RANDOM(i , n )]
複製代碼

如代碼中所示,第 i 次迭代時,元素 A[i] 是從元素 A[i...n]中隨機選取的,在第 i 次迭代後,咱們就不再會改變 A[i]數組

A[i] 位於任意位置j的機率爲 1/n。這個是很容易推導的,好比 A[1] 位於位置 1 的機率爲 1/n,這個顯然,由於 A[1] 不被1到n的元素替換的機率爲 1/n,然後就不會再改變 A[1] 了。而A[1] 位於位置 2 的機率也是 1/n,由於 A[1] 要想位於位置2,則必須在第一次與 A[k] (k=2...n) 交換,同時第二次 A[2]A[k]替換,第一次與 A[k] 交換的機率爲(n-1)/n,而第二次替換機率爲 1/(n-1),因此總的機率是 (n-1)/n * 1/(n-1) = 1/n。同理能夠推導其餘狀況。bash

固然這個條件只能是隨機排列數組的一個必要條件,也就是說,知足元素 A[i] 位於位置 j 的機率爲1/n 不必定就能說明這能夠產生隨機排列數組。由於它可能產生的排列數目少於 n!,儘管機率相等,可是排列數目沒有達到要求,算法導論上面有一個這樣的反例。數據結構

算法 RANDOMIZE-IN-PLACE能夠產生均勻隨機排列,它的證實過程以下:dom

首先給出k排列的概念,所謂 k 排列就是從n個元素中選取k個元素的排列,那麼它一共有 n!/(n-k)! 個 k 排列。數據結構和算法

循環不變式:for循環第i次迭代前,對於每一個可能的i-1排列,子數組A[1...i-1]包含該i-1排列的機率爲 (n-i+1)! / n!

  • 初始化:在第一次迭代前,i=1,則循環不變式指的是對於每一個0排列,子數組A[1...i-1]包含該0排列的機率爲 (n-1+1)! / n! = 1。A[1...0]爲空的數組,0排列則沒有任何元素,所以A包含全部可能的0排列的機率爲1。不變式成立。

  • 維持:假設在第i次迭代前,數組的i-1排列出如今 A[1...i-1] 的機率爲 (n-i+1) !/ n!,那麼在第i次迭代後,數組的全部i排列出如今 A[1...i] 的機率爲 (n-i)! / n!。下面來推導這個結論:

    • 考慮一個特殊的 i 排列 p = {x1, x2, ... xi},它由一個 i-1 排列 p' ={x1, x2,..., xi−1} 後面跟一個 xi 構成。設定兩個事件變量E1和E2:
  • E1爲該算法將排列 p' 放置到 A[1...i-1]的事件,機率由概括假設得知爲 Pr(E1) = (n-i+1)! / n!

  • E2爲在第 i 次迭代時將 xi 放入到 A[i] 的事件。 所以咱們獲得 i 排列出如今 A[1...i] 的機率爲 Pr {E2 ∩ E1} = Pr {E2 | E1} Pr {E1}。而Pr {E2 | E1} = 1/(n − i + 1),因此 Pr {E2 ∩ E1} = Pr {E2 | E1} Pr {E1}= 1 /(n − i + 1) * (n − i + 1)! / n! = (n − i )! / n!

  • 結束:結束的時候 i=n+1,所以能夠獲得 A[1...n] 是一個給定 n 排列的機率爲 1/n!

C實現代碼以下:

void randomInPlace(int a[], int n)
{
    int i;
    for (i = 0; i < n; i++) {
        int rand = randInt(i, n-1);
        swapInt(a, i, rand);
    }
}
複製代碼

擴展

若是上面的隨機排列算法寫成下面這樣,是否也能產生均勻隨機排列?

PERMUTE-WITH-ALL( A , n ) 
	for i ←1 to n do 
		swap A[i] ↔A[RANDOM(1 , n )]
複製代碼

注意,該算法不能產生均勻隨機排列。假定 n=3,則該算法能夠產生3*3*3=27個輸出,而3個元素只有3!=6個不一樣的排列,要使得這些排列出現機率等於 1/6,則必須使得每一個排列出現次數 m 知足m/27=1/6,顯然,沒有這樣的整數符合條件。而實際上各個排列出現的機率以下,如 {1,2,3} 出現的機率爲4/27,不等於 1/6

排 列 概 率
<1, 2, 3> 4/27
<1, 3, 2> 5/27
<2, 1, 3> 5/27
<2, 3, 1> 5/27
<3, 1, 2> 4/27
<3, 2, 1> 4/27

2 隨機選取一個數字

題: 給定一個未知長度的整數流,如何隨機選取一個數?(所謂隨機就是保證每一個數被選取的機率相等)

解1: 若是數據流不是很長,能夠存在數組中,而後再從數組中隨機選取。固然題目說的是未知長度,因此若是長度很大不足以保存在內存中的話,這種解法有其侷限性。

解2: 若是數據流很長的話,能夠這樣:

  • 若是數據流在第1個數字後結束,那麼必選第1個數字。
  • 若是數據流在第2個數字後結束,那麼咱們選第2個數字的機率爲1/2,咱們以1/2的機率用第2個數字替換前面選的隨機數,獲得新的隨機數。
  • ......
  • 若是數據流在第n個數字後結束,那麼咱們選擇第n個數字的機率爲1/n,即咱們以1/n的機率用第n個數字替換前面選的隨機數,獲得新的隨機數。

一個簡單的方法就是使用隨機函數 f(n)=bigrand()%n,其中 bigrand() 返回很大的隨機整數,當數據流到第 n 個數時,若是 f(n)==0,則替換前面的已經選的隨機數,這樣能夠保證每一個數字被選中的機率都是 1/n。如當 n=1 時,則f(1)=0,則選擇第 1 個數,當 n=2 時,則第 2 個數被選中的機率都爲 1/2,以此類推,當數字長度爲 n 時,第 n 個數字被選中的機率爲 1/n。代碼以下(注:在 Linux/MacOS 下,rand() 函數已經能夠返回一個很大的隨機數了,就當作bigrand()用了):

void randomOne(int n)
{
    int i, select = 0;
    for (i = 1; i < n; i++) {
        int rd = rand() % n;
        if (rd == 0) {
            select = i;
        }
    }
    printf("%d\n", select);
}
複製代碼

3 隨機選取M個數字

: 程序輸入包含兩個整數 m 和 n ,其中 m<n,輸出是 0~n-1 範圍內的 m 個隨機整數的有序列表,不容許重複。從機率角度來講,咱們但願獲得沒有重複的有序選擇,其中每一個選擇出現的機率相等。

解1: 先考慮個簡單的例子,當 m=2,n=5 時,咱們須要從 0~4 這 5 個整數中等機率的選取 2 個有序的整數,且不能重複。若是採用以下條件選取:bigrand() % 5 < 2,則咱們選取 0 的機率爲2/5。可是咱們不能採起一樣的機率來選取 1,由於選取了 0 後,咱們應該以 1/4 的機率來選取1,而在沒有選取0的狀況下,咱們應該以 2/4 的機率選取1。選取的僞代碼以下:

select = m
remaining = n
for i = [0, n)
    if (bigrand() % remaining < select)
         print i
         select--
    remaining--
複製代碼

只要知足條件 m<=n,則程序輸出 m 個有序整數,很少很多。不會多選,由於每選擇一個數,select--,這樣當 select 減到 0 後就不會再選了。同時,也不會少選,由於每次都會remaining--,當 select/remaining=1 時,必定會選取一個數。每一個子集被選擇的機率是相等的,好比這裏5選2則共有 C(5,2)=10 個子集,如 {0,1},{0,2}...等,每一個子集被選中的機率都是 1/10

更通常的推導,n選m的子集數目一共有 C(n,m) 個,考慮一個特定的 m 序列,如0...m-1,則選取它的機率爲m/n * (m-1)/(n-1)*....1/(n-m+1)=1/C(n,m),能夠看到機率是相等的。

Knuth 老爺爺很早就提出了這個算法,他的實現以下:

void randomMKnuth(int n, int m)
{
    int i;
    for (i = 0; i < n; i++) {
        if ((rand() % (n-i)) < m) {
            printf("%d ", i);
            m--;
        }
    }
}
複製代碼

解2: 還能夠採用前面隨機排列數組的思想,先對前 m 個數字進行隨機排列,而後排序這 m 個數字並輸出便可。代碼以下:

void randomMArray(int n, int m)
{
    int i, j;
    int *x = (int *)malloc(sizeof(int) * n);
    
    for (i = 0; i < n; i++)
        x[i] = i;

    // 隨機數組
    for (i = 0; i < m; i++) {
        j = randInt(i, n-1);
        swapInt(x, i, j);
    }

    // 對數組前 m 個元素排序
    for (i = 0; i < m; i++) {
        for (j = i+1; j>0 && x[j-1]>x[j]; j--) {
            swapInt(x, j, j-1);
        }
    }

    for (i = 0; i < m; i++) {
        printf("%d ", x[i]);
    }

    printf("\n");
}
複製代碼

4 rand7 生成 rand10 問題

題: 已知一個函數rand7()可以生成1-7的隨機數,每一個數機率相等,請給出一個函數rand10(),該函數可以生成 1-10 的隨機數,每一個數機率相等。

解1: 要產生 1-10 的隨機數,咱們要麼執行 rand7() 兩次,要麼直接乘以一個數字來獲得咱們想要的範圍值。以下面公式(1)和(2)。

idx = 7 * (rand7()-1) + rand7() ---(1) 正確
idx = 8 * rand7() - 7           ---(2) 錯誤
複製代碼

上面公式 (1) 可以產生 1-49 的隨機數,爲何呢?由於 rand7() 的可能的值爲 1-7,兩個 rand7() 則可能產生 49 種組合,且正好是 1-49 這 49 個數,每一個數出現的機率爲 1/49,因而咱們能夠將大於 40 的丟棄,而後取 (idx-1) % 10 + 1 便可。公式(2)是錯誤的,由於它生成的數的機率不均等,並且也沒法生成49個數字。

1  2  3  4  5  6  7
1  1  2  3  4  5  6  7
2  8  9 10  1  2  3  4
3  5  6  7  8  9 10  1
4  2  3  4  5  6  7  8
5  9 10  1  2  3  4  5
6  6  7  8  9 10  *  *
7  *  *  *  *  *  *  *
複製代碼

該解法基於一種叫作拒絕採樣的方法。主要思想是隻要產生一個目標範圍內的隨機數,則直接返回。若是產生的隨機數不在目標範圍內,則丟棄該值,從新取樣。因爲目標範圍內的數字被選中的機率相等,這樣一個均勻的分佈生成了。代碼以下:

int rand7ToRand10Sample() {
    int row, col, idx;
    do {
        row = rand7();
        col = rand7();
        idx = col + (row-1)*7;
    } while (idx > 40);

    return 1 + (idx-1) % 10;
}
複製代碼

因爲row範圍爲1-7,col範圍爲1-7,這樣idx值範圍爲1-49。大於40的值被丟棄,這樣剩下1-40範圍內的數字,經過取模返回。下面計算一下獲得一個知足1-40範圍的數須要進行取樣的次數的指望值:

E(# calls to rand7) = 2 * (40/49) +
                      4 * (9/49) * (40/49) +
                      6 * (9/49)2 * (40/49) +
                      ...

                      ∞
                    = ∑ 2k * (9/49)k-1 * (40/49)
                      k=1

                    = (80/49) / (1 - 9/49)2
                    = 2.45
複製代碼

解2: 上面的方法大概須要2.45次調用 rand7 函數才能獲得 1 個 1-10 範圍的數,下面能夠進行再度優化。對於大於40的數,咱們沒必要立刻丟棄,能夠對 41-49 的數減去 40 可獲得 1-9 的隨機數,而rand7可生成 1-7 的隨機數,這樣能夠生成 1-63 的隨機數。對於 1-60 咱們能夠直接返回,而 61-63 則丟棄,這樣須要丟棄的數只有3個,相比前面的9個,效率有所提升。而對於61-63的數,減去60後爲 1-3,rand7 產生 1-7,這樣能夠再度利用產生 1-21 的數,對於 1-20 咱們則直接返回,對於 21 則丟棄。這時,丟棄的數就只有1個了,優化又進一步。固然這裏面對rand7的調用次數也是增長了的。代碼以下,優化後的指望大概是 2.2123。

int rand7ToRand10UtilizeSample() {
    int a, b, idx;
    while (1) {
        a = randInt(1, 7);
        b = randInt(1, 7);
        idx = b + (a-1)*7;
        if (idx <= 40)
            return 1 + (idx-1)%10;

        a = idx-40;
        b = randInt(1, 7);
        // get uniform dist from 1 - 63
        idx = b + (a-1)*7;
        if (idx <= 60)
            return 1 + (idx-1)%10;

        a = idx-60;
        b = randInt(1, 7);
        // get uniform dist from 1-21
        idx = b + (a-1)*7;
        if (idx <= 20)
            return 1 + (idx-1)%10;
    }
}
複製代碼

5 趣味機率題

1)稱球問題

: 有12個小球,其中一個是壞球。給你一架天平,須要你用最少的稱次數來肯定哪一個小球是壞的,而且它究竟是輕了仍是重了。

: 以前有總結過二分查找算法,咱們知道二分法能夠加快有序數組的查找。類似的,好比在數字遊戲中,若是要你猜一個介於 1-64 之間的數字,用二分法在6次內確定能猜出來。可是稱球問題卻不一樣。稱球問題這裏 12 個小球,壞球多是其中任意一個,這就有 12 種可能性。而壞球多是重了或者輕了這2種狀況,因而這個問題一共有 12*2 = 24 種可能性。每次用天平稱,天平能夠輸出的是 平衡、左重、右重 3 種可能性,即稱一次能夠將問題可能性縮小到原來的 1/3,則一共 24 種可能性能夠在 3 次內稱出來(3^3 = 27)。

爲何最直觀的稱法 6-6 不是最優的?在 6-6 稱的時候,天平平衡的可能性是0,而最優策略應該是讓天平每次稱量時的機率均等,這樣才能三等分答案的全部可能性。

具體怎麼實施呢? 將球編號爲1-12,採用 4, 4 稱的方法。

  • 咱們先將 1 2 3 45 6 7 8 進行第1次稱重。
  • 若是第1次平衡,則壞球確定在 9-12 號中。則此時只剩下 9-12 4個球,可能性爲 9- 10- 11- 12- 9+ 10+ 11+ 12+ 這8種可能。接下來將 9 10 111 2 3稱第2次:若是平衡,則 12 號小球爲壞球,將12號小球與1號小球稱第3次便可確認輕仍是重。若是不平衡,則若是重了說明壞球重了,繼續將9和10號球稱量,重的爲壞球,平衡的話則11爲壞球。
  • 若是第1次不平衡,則壞球確定在 1-8號中。則還剩下的可能性是 1+ 2+ 3+ 4+ 5- 6- 7- 8- 或者 1- 2- 3- 4- 5+ 6+ 7+ 8+,若是是1 2 3 4 這邊重,則能夠將 1 2 63 4 5 稱,若是平衡,則必然是 7 8 輕了,再稱一次7和1,即可以判斷7和8哪一個是壞球了。若是不平衡,假定是 1 2 6 這邊重,則能夠判斷出 1 2 重了或者 5 輕了,爲何呢?由於若是是3+ 4+ 6-,則 1 2 3 45 6 7 8 重,可是 1 2 6 應該比 3 4 5 輕。其餘狀況同理,最多3次便可找出壞球。

下面這個圖更加清晰說明了這個原理。

稱球問題圖示

2)生男生女問題

題: 在重男輕女的國家裏,男女的比例是多少?在一個重男輕女的國家裏,每一個家庭都想生男孩,若是他們生的孩子是女孩,就再生一個,直到生下的是男孩爲止。這樣的國家,男女比例會是多少?

解: 仍是1:1。在全部出生的第一個小孩中,男女比例是1:1;在全部出生的第二個小孩中,男女比例是1:1;.... 在全部出生的第n個小孩中,男女比例仍是1:1。因此總的男女比例是1:1。

3)約會問題

題: 兩人相約5點到6點在某地會面,先到者等20分鐘後離去,求這兩人可以會面的機率。

解: 設兩人分別在5點X分和5點Y分到達目的地,則他們可以會面的條件是 |X-Y| <= 20,而整個範圍爲 S={(x, y): 0 =< x <= 60,  0=< y <= 60},若是畫出座標軸的話,會面的狀況爲座標軸中表示的面積,機率爲 (60^2 - 40^2) / 60^2 = 5/9

4)帽子問題

題: 有n位顧客,他們每一個人給餐廳的服務生一頂帽子,服務生以隨機的順序歸還給顧客,請問拿到本身帽子的顧客的指望數是多少?

解: 使用指示隨機變量來求解這個問題會簡單些。定義一個隨機變量X等於可以拿到本身帽子的顧客數目,咱們要計算的是 E[X]。對於 i=1, 2 ... n,定義 Xi =I {顧客i拿到本身的帽子},則 X=X1+X2+...Xn。因爲歸還帽子的順序是隨機的,因此每一個顧客拿到本身帽子的機率爲1/n,即 Pr(Xi=1)=1/n,從而 E(Xi)=1/n,因此E(X)=E(X1 + X2 + ...Xn)= E(X1)+E(X2)+...E(Xn)=n*1/n = 1,即大約有1個顧客能夠拿到本身的帽子。

5)生日悖論

題: 一個房間至少要有多少人,才能使得有兩我的的生日在同一天?

解: 對房間k我的中的每一對(i, j)定義指示器變量 Xij = {i與j生日在同一天} ,則i與j生日相同時,Xij=1,不然 Xij=0。兩我的在同一天生日的機率 Pr(Xij=1)=1/n 。則用X表示同一天生日的兩人對的數目,則 E(X)=E(∑ki=1∑kj=i+1Xij) = C(k,2)*1/n = k(k-1)/2n,令 k(k-1)/2n >=1,可獲得 k>=28,即至少要有 28 我的,才能指望兩我的的生日在同一天。

6)機率逆推問題

題: 若是在高速公路上30分鐘內看到一輛車開過的概率是0.95,那麼在10分鐘內看到一輛車開過的概率是多少?(假設常機率條件下)

解: 假設10分鐘內看到一輛車開過的機率是x,那麼沒有看到車開過的機率就是1-x,30分鐘沒有看到車開過的機率是 (1-x)^3,也就是 0.05。因此獲得方程 (1-x)^3 = 0.05 ,解方程獲得 x 大約是 0.63。

參考資料

相關文章
相關標籤/搜索