1. 程式人生 > >那些年,面試中常見的資料結構基礎和演算法題(下)

那些年,面試中常見的資料結構基礎和演算法題(下)

前言

這是 資料結構和演算法面試題系列的下半部分,這部分主要是演算法類 包括二分查詢、排序演算法、遞迴演算法、隨機演算法、揹包問題、數字問題等演算法相關內容。本系列完整程式碼在 github 建了個倉庫,所有程式碼都重新整理和做了一些基本的測試,程式碼倉庫地址在這裡: shishujuan/dsalg: 資料結構與算法系列彙總,如有錯誤,請在文章下面評論指出或者在 github 給我留言,我好及時改正以免誤導其他朋友。

文章末尾有系列目錄,可以按需取閱,如果需要測試,亦可以將倉庫程式碼 clone 下來進行各種測試。如有錯誤或者引用不全、有侵權的地方,請大家給我指出,我好及時調整改正。如果本系列有幫助到你,也歡迎點贊或者在 github 上 star

✨✨,十分感謝。

資料結構和演算法面試題系列—二分查詢演算法詳解

0.概述

二分查詢本身是個簡單的演算法,但是正是因為其簡單,更容易寫錯。甚至於在二分查詢演算法剛出現的時候,也是存在 bug 的(溢位的 bug),這個 bug 直到幾十年後才修復(見《程式設計珠璣》)。本文打算對二分查詢演算法進行總結,並對由二分查詢引申出來的問題進行分析和彙總。若有錯誤,請指正。本文完整程式碼在 這裡

1.二分查詢基礎

相信大家都知道二分查詢的基本演算法,如下所示,這就是二分查詢演算法程式碼:

/**
 * 基本二分查詢演算法
 */
int binarySearch(int a[], int n, int t)
{
    int l = 0, u = n - 1;
    while
(l <= u) { int m = l + (u - l) / 2; // 同(l+u)/ 2,這裡是為了溢位 if (t > a[m]) l = m + 1; else if (t < a[m]) u = m - 1; else return m; } return -(l+1); } 複製程式碼

演算法的思想就是:從陣列中間開始,每次排除一半的資料,時間複雜度為 O(lgN)。這依賴於陣列有序這個性質。如果 t 存在陣列中,則返回t在陣列的位置;否則,不存在則返回 -(l+1)

這裡需要解釋下為什麼 t 不存在陣列中時不是返回 -1 而要返回 -(l+1)。首先我們可以觀察 l 的值,如果查詢不成功,則 l 的值恰好是 t 應該在陣列中插入的位置。

舉個例子,假定有序陣列 a={1, 3, 4, 7, 8}, 那麼如果t = 0,則顯然t不在陣列中,則二分查詢演算法最終會使得l = 0 > u=-1退出迴圈;如果 t = 9,則 t 也不在陣列中,則最後 l = 5 > u = 4 退出迴圈。如果 t=5,則最後l=3 > u=2退出迴圈。因此在一些演算法中,比如DHT(一致性雜湊)中,就需要這個返回值來使得新加入的節點可以插入到合適的位置中,在求最長遞增子序列的 NlgN 演算法中,也用到了這一點,參見博文最長遞增子序列演算法

還有一個小點就是之所以返回 -(l+1) 而不是直接返回 -l 是因為 l 可能為 0,如果直接返回 -l 就無法判斷是正常返回位置 0 還是查詢不成功返回的 0。

2.查詢有序陣列中數字第一次出現位置

現在考慮一個稍微複雜點的問題,如果有序陣列中有重複數字,比如陣列 a={1, 2, 3, 3, 5, 7, 8},需要在其中找出 3 第一次出現的位置。這裡3第一次出現位置為 2。這個問題在《程式設計珠璣》第九章有很好的分析,這裡就直接用了。演算法的精髓在於迴圈不變式的巧妙設計,程式碼如下:

/**
 * 二分查詢第一次出現位置
 */
int binarySearchFirst(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*迴圈不變式a[l]<t<=a[u] && l<u*/
        int m = l + (u - l) / 2; //同(l+u)/ 2
        if (t > a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1=u && a[l]<t<=a[u]*/
    int p = u;
    if (p>=n || a[p]!=t)
        p = -1;
    return p;
}
複製程式碼

演算法分析:設定兩個不存在的元素 a[-1]和 a[n],使得 a[-1] < t <= a[n],但是我們並不會去訪問者兩個元素,因為(l+u)/2 > l=-1, (l+u)/2 < u=n。迴圈不變式為l<u && t>a[l] && t<=a[u] 。迴圈退出時必然有 l+1=u, 而且 a[l] < t <= a[u]。迴圈退出後u的值為t可能出現的位置,其範圍為[0, n],如果 t 在陣列中,則第一個出現的位置 p=u,如果不在,則設定 p=-1返回。該演算法的效率雖然解決了更為複雜的問題,但是其效率比初始版本的二分查詢還要高,因為它在每次迴圈中只需要比較一次,前一程式則通常需要比較兩次。

舉個例子:對於陣列 a={1, 2, 3, 3, 5, 7, 8},我們如果查詢 t=3,則可以得到 p=u=2,如果查詢 t=4,a[3]<t<=a[4], 所以p=u=4,判斷 a[4] != t,所以設定p=-1。 一種例外情況是 u>=n, 比如t=9,則 u=7,此時也是設定 p=-1.特別注意的是,l=-1,u=n 這兩個值不能寫成l=0,u=n-1。雖然這兩個值不會訪問到,但是如果改成後面的那樣,就會導致二分查詢失敗,那樣就訪問不到第一個數字。如在 a={1,2,3,4,5}中查詢 1,如果初始設定 l=0,u=n-1,則會導致查詢失敗。

擴充套件 如果要查詢數字在陣列中最後出現的位置呢?其實這跟上述演算法是類似的,稍微改一下上面的演算法就可以了,程式碼如下:

/**
 * 二分查詢最後一次出現位置
 */
int binarySearchLast(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*迴圈不變式, a[l] <= t < a[u]*/
        int m = l + (u - l) / 2;
        if (t >= a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1 = u && a[l] <= t < a[u]*/
    int p = l;
    if (p<=-1 || a[p]!=t)
        p = -1;
    return p;
}
複製程式碼

當然還有一種方法可以將查詢數字第一次出現和最後一次出現的程式碼寫在一個程式中,只需要對原始的二分查詢稍微修改即可,程式碼如下:

/**
 * 二分查詢第一次和最後一次出現位置
 */
int binarySearchFirstAndLast(int a[], int n, int t, int firstFlag)
{
    int l = 0;
    int u = n - 1;
    while(l <= u) {
        int m = l + (u - l) / 2;
        if(a[m] == t) { //找到了,判斷是第一次出現還是最後一次出現
            if(firstFlag) { //查詢第一次出現的位置
                if(m != 0 && a[m-1] != t)
                    return m;
                else if(m == 0)
                    return 0;
                else
                    u = m - 1;
            } else {   //查詢最後一次出現的位置
                if(m != n-1 && a[m+1] != t)
                    return m;
                else if(m == n-1)
                    return n-1;
                else
                    l = m + 1;
            }
        }
        else if(a[m] < t)
            l = m + 1;
        else
            u = m - 1;
    }

    return -1;
}
複製程式碼

3.旋轉陣列元素查詢問題

題目

把一個有序陣列最開始的若干個元素搬到陣列的末尾,我們稱之為陣列的旋轉。例如陣列{3, 4, 5, 1, 2}為{1, 2, 3, 4, 5}的一個旋轉。現在給出旋轉後的陣列和一個數,旋轉了多少位不知道,要求給出一個演算法,算出給出的數在該陣列中的下標,如果沒有找到這個數,則返回 -1。要求查詢次數不能超過 n。

分析

由題目可以知道,旋轉後的陣列雖然整體無序了,但是其前後兩部分是部分有序的。由此還是可以使用二分查詢來解決該問題的。

解1:兩次二分查詢

首先確定陣列分割點,也就是說分割點兩邊的陣列都有序。比如例子中的陣列以位置2分割,前面部分{3,4,5}有序,後半部分{1,2}有序。然後對這兩部分分別使用二分查詢即可。程式碼如下:

/**
 * 旋轉陣列查詢-兩次二分查詢
 */
int binarySearchRotateTwice(int a[], int n, int t)
{
    int p = findRotatePosition(a, n); //找到旋轉位置
    if (p == -1)
        return binarySearchFirst(a, n, t); //如果原陣列有序,則直接二分查詢即可

    int left = binarySearchFirst(a, p+1, t); //查詢左半部分
    if (left != -1)
        return left; //左半部分找到,則直接返回

    int right = binarySearchFirst(a+p+1, n-p-1, t); //左半部分沒有找到,則查詢右半部分
    if (right == -1)
        return -1;

    return right+p+1;  //返回位置,注意要加上p+1
}

/**
 * 查詢旋轉位置
 */
int findRotatePosition(int a[], int n)
{
    int i;
    for (i = 0; i < n-1; i++) {
        if (a[i+1] < a[i])
            return i;
    }
    return -1;
}
複製程式碼

解2:一次二分查詢

二分查詢演算法有兩個關鍵點:1)陣列有序;2)根據當前區間的中間元素與t的大小關係,確定下次二分查詢在前半段區間還是後半段區間進行。

仔細分析該問題,可以發現,每次根據 lu 求出 m 後,m 左邊([l, m])和右邊([m, u])至少一個是有序的。a[m]分別與a[l]和a[u]比較,確定哪一段是有序的。

  • 如果左邊是有序的,若 t<a[m] && t>a[l], 則 u=m-1;其他情況,l =m+1
  • 如果右邊是有序的,若 t> a[m] && t<a[u]l=m+1;其他情況,u =m-1; 程式碼如下:
/**
 * 旋轉陣列二分查詢-一次二分查詢
 */
int binarySearchRotateOnce(int a[], int n, int t)
{
    int l = 0, u = n-1;
    while (l <= u) {
        int m = l + (u-l) / 2;
        if (t == a[m])
            return m;
        if (a[m] >= a[l]) { //陣列左半有序
            if (t >= a[l] && t < a[m])
                u = m - 1;
            else
                l = m + 1;
        } else {       //陣列右半段有序
            if (t > a[m] && t <= a[u])
                l = m + 1;
            else
                u = m - 1;
        }   
    }   
    return -1; 
}
複製程式碼

資料結構和演算法面試題系列—排序演算法之基礎排序

0.概述

排序演算法也是面試中常常提及的內容,問的最多的應該是快速排序、堆排序。這些排序演算法很基礎,但是如果平時不怎麼寫程式碼的話,面試的時候總會出現各種 bug。雖然思想都知道,但是就是寫不出來。本文打算對各種排序演算法進行一個彙總,包括插入排序、氣泡排序、選擇排序、計數排序、歸併排序,基數排序、桶排序、快速排序等。快速排序比較重要,會單獨寫一篇,而堆排序見本系列的二叉堆那篇文章即可。

需要提到的一點就是:插入排序,氣泡排序,歸併排序,計數排序都是穩定的排序,而其他排序則是不穩定的。本文完整程式碼在 這裡

1.插入排序

插入排序是很基本的排序,特別是在資料基本有序的情況下,插入排序的效能很高,最好情況可以達到O(N),其最壞情況和平均情況時間複雜度都是 O(N^2)。程式碼如下:

/**
 * 插入排序
 */
void insertSort(int a[], int n)
{
    int i, j;
    for (i = 1; i < n; i++) {
        /*
         * 迴圈不變式:a[0...i-1]有序。每次迭代開始前,a[0...i-1]有序,
         * 迴圈結束後i=n,a[0...n-1]有序
         * */
        int key = a[i];
        for (j = i; j > 0 && a[j-1] > key; j--) {
            a[j] = a[j-1];
        }
        a[j] = key;
    }
}
複製程式碼

2.希爾排序

希爾排序內部呼叫插入排序來實現,通過對 N/2,N/4...1階分別排序,最後得到整體的有序。

/**
 * 希爾排序
 */
void shellSort(int a[], int n)
{
    int gap;
    for (gap = n/2; gap > 0; gap /= 2) {
        int i;
        for (i = gap; i < n; i++) {
            int key = a[i], j;
            for (j = i; j >= gap && key < a[j-gap]; j -= gap) {
                a[j] = a[j-gap];
            }
            a[j] = key;
        }
    }
}
複製程式碼

3.選擇排序

選擇排序的思想就是第 i 次選取第 i 小的元素放在位置 i。比如第 1 次就選擇最小的元素放在位置 0,第 2 次選擇第二小的元素放在位置 1。選擇排序最好和最壞時間複雜度都為 O(N^2)。程式碼如下:

/**
 * 選擇排序
 */
void selectSort(int a[], int n)
{
    int i, j, min, tmp;
    for (i = 0; i < n-1; i++) {
        min = i;
        for (j = i+1; j < n; j++) {
            if (a[j] < a[min])
                min = j;
        }
        if (min != i)
            tmp = a[i], a[i] = a[min], a[min] = tmp; //交換a[i]和a[min]
    }
}
複製程式碼

迴圈不變式:在外層迴圈執行前,a[0...i-1]包含 a 中最小的 i 個數,且有序。

  • 初始時,i=0a[0...-1] 為空,顯然成立。

  • 每次執行完成後,a[0...i] 包含 a 中最小的 i+1 個數,且有序。即第一次執行完成後,a[0...0] 包含 a 最小的 1 個數,且有序。

  • 迴圈結束後,i=n-1,則 a[0...n-2]包含 a 最小的 n-1 個數,且已經有序。所以整個陣列有序。

4.氣泡排序

氣泡排序時間複雜度跟選擇排序相同。其思想就是進行 n-1 趟排序,每次都是把最小的數上浮,像魚冒泡一樣。最壞情況為 O(N^2)。程式碼如下:

/**
 * 氣泡排序-經典版
 */
void bubbleSort(int a[], int n)
{
    int i, j, tmp;
    for (i = 0; i < n; i++) {
        for (j = n-1; j >= i+1; j--) {
            if (a[j] < a[j-1])
                tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
        }
    }
}
複製程式碼

迴圈不變式:在迴圈開始迭代前,子陣列 a[0...i-1] 包含了陣列 a[0..n-1]i-1 個最小值,且是排好序的。

對氣泡排序的一個改進就是在每趟排序時判斷是否發生交換,如果一次交換都沒有發生,則陣列已經有序,可以不用繼續剩下的趟數直接退出。改進後代碼如下:

/**
 * 氣泡排序-優化版
 */
void betterBubbleSort(int a[], int n)
{
    int tmp, i, j;
    for (i = 0; i < n; i++) {
        int sorted = 1;
        for (j = n-1; j >= i+1; j--) {
            if (a[j] < a[j-1]) {
                tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
                sorted = 0;
            }   
        }   
        if (sorted)
            return ;
    }   
}
複製程式碼

5.計數排序

假定陣列為 a[0...n-1] ,陣列中存在重複數字,陣列中最大數字為k,建立兩個輔助陣列 b[]c[]b[] 用於儲存排序後的結果,c[] 用於儲存臨時值。時間複雜度為 O(N),適用於數字範圍較小的陣列。

計數排序

計數排序原理如上圖所示,程式碼如下:

/**
 * 計數排序
 */
void countingSort(int a[], int n) 
{
    int i, j;
    int *b = (int *)malloc(sizeof(int) * n);
    int k = maxOfIntArray(a, n); // 求陣列最大元素
    int *c = (int *)malloc(sizeof(int) * (k+1));  //輔助陣列

    for (i = 0; i <= k; i++)
        c[i] = 0;

    for (j = 0; j < n; j++)
        c[a[j]] = c[a[j]] + 1; //c[i]包含等於i的元素個數

    for (i = 1; i <= k; i++)
        c[i] = c[i] + c[i-1];  //c[i]包含小於等於i的元素個數

    for (j = n-1; j >= 0; j--) {  // 賦值語句
        b[c[a[j]]-1] = a[j]; //結果存在b[0...n-1]中
        c[a[j]] = c[a[j]] - 1;
    }

    /*方便測試程式碼,這一步賦值不是必須的*/
    for (i = 0; i < n; i++) {
        a[i] = b[i];
    }

    free(b);
    free(c);
}
複製程式碼

擴充套件: 如果程式碼中的給陣列 b[] 賦值語句 for (j=n-1; j>=0; j--) 改為 for(j=0; j<=n-1; j++),該程式碼仍然正確,只是排序不再穩定。

6.歸併排序

歸併排序通過分治演算法,先排序好兩個子陣列,然後將兩個子陣列歸併。時間複雜度為 O(NlgN)。程式碼如下:

/*
 * 歸併排序-遞迴
 * */
void mergeSort(int a[], int l, int u) 
{
    if (l < u) {
        int m = l + (u-l)/2;
        mergeSort(a, l, m);
        mergeSort(a, m + 1, u);
        merge(a, l, m, u);
    }
}
 
/**
 * 歸併排序合併函式
 */
void merge(int a[], int l, int m, int u) 
{
    int n1 = m - l + 1;
    int n2 = u - m;

    int left[n1], right[n2];
    int i, j;
    for (i = 0; i < n1; i++) /* left holds a[l..m] */
        left[i] = a[l + i];

    for (j = 0; j < n2; j++) /* right holds a[m+1..u] */
        right[j] = a[m + 1 + j];

    i = j = 0;
    int k = l;
    while (i < n1 && j < n2) {
        if (left[i] < right[j])
            a[k++] = left[i++];
        else
            a[k++] = right[j++];
    }
    while (i < n1) /* left[] is not exhausted */
        a[k++] = left[i++];
    while (j < n2) /* right[] is not exhausted */
        a[k++] = right[j++];
}
複製程式碼

擴充套件:歸併排序的非遞迴實現怎麼做?

歸併排序的非遞迴實現其實是最自然的方式,先兩兩合併,而後再四四合並等,就是從底向上的一個過程。程式碼如下:

/**
 * 歸併排序-非遞迴
 */
void mergeSortIter(int a[], int n)
{
    int i, s=2;
    while (s <= n) {
        i = 0;
        while (i+s <= n){
            merge(a, i, i+s/2-1, i+s-1);
            i += s;
        }

        //處理末尾殘餘部分
        merge(a, i, i+s/2-1, n-1);
        s*=2;
    }
    //最後再從頭到尾處理一遍
    merge(a, 0, s/2-1, n-1);
}
複製程式碼

7.基數排序、桶排序

基數排序的思想是對數字每一位分別排序(注意這裡必須是穩定排序,比如計數排序等,否則會導致結果錯誤),最後得到整體排序。假定對 N 個數字進行排序,如果數字有 d 位,每一位可能的最大值為 K,則每一位的穩定排序需要 O(N+K) 時間,總的需要 O(d(N+K)) 時間,當 d 為常數,K=O(N) 時,總的時間複雜度為O(N)。

基數排序

而桶排序則是在輸入符合均勻分佈時,可以以線性時間執行,桶排序的思想是把區間 [0,1) 劃分成 N 個相同大小的子區間,將 N 個輸入均勻分佈到各個桶中,然後對各個桶的連結串列使用插入排序,最終依次列出所有桶的元素。

桶排序

這兩種排序使用場景有限,程式碼就略過了,更詳細可以參考《演算法導論》的第8章。

資料結構和演算法面試題系列—排序演算法之快速排序

0.概述

快速排序也是基於分治模式,類似歸併排序那樣,不同的是快速排序劃分最後不需要merge。對一個數組 A[p..r] 進行快速排序分為三個步驟:

  • 劃分: 陣列 A[p...r] 被劃分為兩個子陣列 A[p...q-1]A[q+1...r],使得 A[p...q-1] 中每個元素都小於等於 A[q],而 A[q+1...r] 每個元素都大於 A[q]。劃分流程見下圖。
  • 解決: 通過遞迴呼叫快速排序,對子陣列分別排序即可。
  • 合併:因為兩個子陣列都已經排好序了,且已經有大小關係了,不需要做任何操作。

快速排序劃分

快速排序演算法不算複雜的演算法,但是實際寫程式碼的時候卻是最容易出錯的程式碼,寫的不對就容易死迴圈或者劃分錯誤,本文程式碼見 這裡

1.樸素的快速排序

這個樸素的快速排序有個缺陷就是在一些極端情況如所有元素都相等時(或者元素本身有序,如 a[] = {1,2,3,4,5}等),樸素的快速演算法時間複雜度為 O(N^2),而如果能夠平衡劃分陣列則時間複雜度為 O(NlgN)

/**
 * 快速排序-樸素版本
 */
void quickSort(int a[], int l, int u)
{
    if (l >= u) return;

    int q = partition(a, l, u);
    quickSort(a, l, q-1);
    quickSort(a, q+1, u);
}

/**
 * 快速排序-劃分函式
 */
int partition(int a[], int l, int u)
{
    int i, q=l;
    for (i = l+1; i <= u; i++) {
        if (a[i] < a[l])
            swapInt(a, i, ++q);
    }
    swapInt(a, l, q);
    return q;
}
複製程式碼

2.改進-雙向劃分的快速排序

一種改進方法就是採用雙向劃分,使用兩個變數 iji 從左往右掃描,移過小元素,遇到大元素停止;j 從右往左掃描,移過大元素,遇到小元素停止。然後測試i和j是否交叉,如果交叉則停止,否則交換 ij 對應的元素值。

注意,如果陣列中有相同的元素,則遇到相同的元素時,我們停止掃描,並交換 ij 的元素值。雖然這樣交換次數增加了,但是卻將所有元素相同的最壞情況由 O(N^2) 變成了差不多 O(NlgN) 的情況。比如陣列 A={2,2,2,2,2}, 則使用樸素快速排序方法,每次都是劃分 n 個元素為 1 個和 n-1 個,時間複雜度為 O(N^2),而使用雙向劃分後,第一次劃分的位置是 2,基本可以平衡劃分兩部分。程式碼如下:

/**
 * 快速排序-雙向劃分函式
 */
int partitionLR(int a[], int l, int u, int pivot)
{
    int i = l;
    int j = u+1;
    while (1) {
        do {
            i++;
        } while (a[i] < pivot && i <= u); //注意i<=u這個判斷條件,不能越界。

        do {
            j--;
        } while (a[j] > pivot);

        if (i > j) break;

        swapInt(a, i, j);
    }

    // 注意這裡是交換l和j,而不是l和i,因為i與j交叉後,a[i...u]都大於等於樞紐元t,
    // 而樞紐元又在最左邊,所以不能與i交換。只能與j交換。
    swapInt(a, l, j);

    return j;
}

/**
 * 快速排序-雙向劃分法
 */
void quickSortLR(int a[], int l, int u)
{
    if (l >= u) return;

    int pivot = a[l];
    int q = partitionLR(a, l, u, pivot);
    quickSortLR(a, l, q-1);
    quickSortLR(a, q+1, u);
}
複製程式碼

雖然雙向劃分解決了所有元素相同的問題,但是對於一個已經排好序的陣列還是會達到 O(N^2) 的複雜度。此外,雙向劃分還要注意的一點是程式碼中迴圈的寫法,如果寫成 while(a[i]<t) {i++;} 等形式,則當左右劃分的兩個值都等於樞紐元時,會導致死迴圈。

3.繼續改進—隨機法和三數取中法取樞紐元

為了解決上述問題,可以進一步改進,通過隨機選取樞紐元或三數取中方式來獲取樞紐元,然後進行雙向劃分。三數取中指的就是從陣列A[l... u]中選擇左中右三個值進行排序,並使用中值作為樞紐元。如陣列 A[] = {1, 3, 5, 2, 4},則我們對 A[0]、A[2]、A[4] 進行排序,選擇中值 A[4](元素4) 作為樞紐元,並將其交換到 a[l] ,最後陣列變成 A[] = {4 3 5 2 1},然後跟之前一樣雙向排序即可。

/**
 * 隨機選擇樞紐元
 */
int pivotRandom(int a[], int l, int u)
{
    int rand = randInt(l, u);
    swapInt(a, l, rand); // 交換樞紐元到位置l
    return a[l];
}

/**
 * 三數取中選擇樞紐元
 */
int pivotMedian3(int a[], int l, int u)
{
     int m = l + (u-l)/2;

     /*
      * 三數排序
      */
     if( a[l] > a[m] )
        swapInt(a, l, m);

     if( a[l] > a[u] )
        swapInt(a, l, u);

     if( a[m] > a[u] )
        swapInt(a, m, u);

     /* assert: a[l] <= a[m] <= a[u] */
     swapInt(a, m, l); // 交換樞紐元到位置l

     return a[l];
}
複製程式碼

此外,在資料基本有序的情況下,使用插入排序可以得到很好的效能,而且在排序很小的子陣列時,插入排序比快速排序更快,可以在陣列比較小時選用插入排序,而大陣列才用快速排序。

4.非遞迴寫快速排序

非遞迴寫快速排序著實比較少見,不過練練手總是好的。需要用到棧,注意壓棧的順序。程式碼如下:

/**
 * 快速排序-非遞迴版本
 */
void quickSortIter(int a[], int n)
{
    Stack *stack = stackNew(n);
    int l = 0, u = n-1;
    int p = partition(a, l, u);

    if (p-1 > l) { //左半部分兩個邊界值入棧
        push(stack, p-1); 
        push(stack, l);
    }

    if (p+1 < u) { //右半部分兩個邊界值入棧
        push(stack, u);
        push(stack, p+1);
    }

    while (!IS_EMPTY(stack)) { //棧不為空,則迴圈劃分過程
        l = pop(stack);
        u = pop(stack);
        p = partition(a, l, u);

        if (p-1 > l) {
            push(stack, p-1);
            push(stack, l);
        }

        if (p+1 < u) {
            push(stack, u);
            push(stack, p+1);
        }
    }
}
複製程式碼

資料結構和演算法面試題系列—隨機演算法總結

0.概述

隨機演算法涉及大量概率論知識,有時候難得去仔細看推導過程,當然能夠完全瞭解推導的過程自然是有好處的,如果不瞭解推導過程,至少記住結論也是必要的。本文總結最常見的一些隨機演算法的題目,是幾年前找工作的時候寫的。需要說明的是,這裡用到的隨機函式 randInt(a, b) 假定它能隨機的產生範圍 [a,b] 內的整數,即產生每個整數的概率相等(雖然在實際中並不一定能實現,不過不要太在意,這個世界很多事情都很隨機)。本文程式碼在 這裡

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。同理可以推導其他情況。

當然這個條件只能是隨機排列陣列的一個必要條件,也就是說,滿足元素 A[i] 位於位置 j 的概率為1/n 不一定就能說明這可以產生隨機排列陣列。因為它可能產生的排列數目少於 n!,儘管概率相等,但是排列數目沒有達到要求,演算法導論上面有一個這樣的反例。

演算法 RANDOMIZE-IN-PLACE可以產生均勻隨機排列,它的證明過程如下:

首先給出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。

資料結構和演算法面試題系列—遞迴演算法總結

0.概述

前面總結了隨機演算法,這次再把以前寫的遞迴演算法的文章梳理一下,這篇文章主要是受到宋勁鬆老師寫的《Linux C程式設計》的遞迴章節啟發寫的。最能體現演算法精髓的非遞迴莫屬了,希望這篇文章對初學遞迴或者對遞迴有困惑的朋友們能有所幫助,如有錯誤,也懇請各路大牛指正。二叉樹的遞迴示例程式碼請參見倉庫的 binary_tree 目錄,本文其他程式碼在 這裡

1.遞迴演算法初探

本段內容主要摘自《linux C一站式程式設計》,作者是宋勁鬆老師,這是我覺得目前看到的國內關於Linux C程式設計的最好的技術書籍之一,強烈推薦下!

關於遞迴的一個簡單例子是求整數階乘,n!=n*(n-1)!,0!=1 。則可以寫出如下的遞迴程式:

int factorial(int n)
{
    if (n == 0)
        return 1;
    else {
        int recurse = factorial(n-1);
        int result = n * recurse;
        return result;
    }
}
複製程式碼

factorial這個函式就是一個遞迴函式,它呼叫了它自己。自己直接或間接呼叫自己的函式稱為遞迴函式。如果覺得迷惑,可以把 factorial(n-1) 這一步看成是在呼叫另一個函式--另一個有著相同函式名和相同程式碼的函式,呼叫它就是跳到它的程式碼裡執行,然後再返回 factorial(n-1) 這個呼叫的下一步繼續執行。

為了證明遞迴演算法的正確性,我們可以一步步跟進去看執行結果。記得剛學遞迴演算法的時候,老是有丈二和尚摸不著頭腦的感覺,那時候總是想著把遞迴一步步跟進去看執行結果。遞迴層次少還算好辦,但是層次一多,頭就大了,完全不知道自己跟到了遞迴的哪一層。比如求階乘,如果只是factorial(3)跟進去問題還不大,但是若是factorial(100)要跟進去那真的會煩死人。

事實上,我們並不是每個函式都需要跟進去看執行結果的,比如我們在自己的函式中呼叫printf函式時,並沒有鑽進去看它是怎麼列印的,因為我們相信它能完成列印工作。 我們在寫factorial函式時有如下程式碼:

int recurse = factorial(n-1);
int result = n * recurse;
複製程式碼

這時,如果我們相信factorial是正確的,那麼傳遞引數為n-1它就會返回(n-1)!,那麼result=n*(n-1)!=n!,從而這就是factorial(n)