那些年,面試中常見的資料結構基礎和演算法題(下) | 掘金技術徵文

ssjhust發表於2019-03-03

前言

這是 資料結構和演算法面試題系列的下半部分,這部分主要是演算法類 包括二分查詢、排序演算法、遞迴演算法、隨機演算法、揹包問題、數字問題等演算法相關內容。本系列完整程式碼在 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)的結果。

當然這有點奇怪:我們還沒寫完factorial這個函式,憑什麼要相信factorial(n-1)是正確的?如果你相信你正在寫的遞迴函式是正確的,並呼叫它,然後在此基礎上寫完這個遞迴函式,那麼它就會是正確的,從而值得你相信它正確。

這麼說還是有點玄乎,我們從數學上嚴格證明一下 factorial 函式的正確性。剛才說了,factorial(n) 的正確性依賴於 factorial(n-1) 的正確性,只要後者正確,在後者的結果上乘個 n 返回這一步顯然也沒有疑問,那麼我們的函式實現就是正確的。因此要證明factorial(n) 的正確性就是要證明 factorial(n-1) 的正確性,同理,要證明factorial(n-1) 的正確性就是要證明 factorial(n-2) 的正確性,依此類推下去,最後是:要證明 factorial(1) 的正確性就是要證明 factorial(0) 的正確性。而factorial(0) 的正確性不依賴於別的函式呼叫,它就是程式中的一個小的分支return 1; 這個 1 是我們根據階乘的定義寫的,肯定是正確的,因此 factorial(1) 的實現是正確的,因此 factorial(2) 也正確,依此類推,最後 factorial(n) 也是正確的。

其實這就是在中學時學的數學歸納法,用數學歸納法來證明只需要證明兩點:Base Case 正確,遞推關係正確。寫遞迴函式時一定要記得寫 Base Case,否則即使遞推關係正確,整個函式也不正確。如果 factorial 函式漏掉了 Base Case,那麼會導致無限迴圈。

2.遞迴經典問題

從上一節的一個關於求階乘的簡單例子的論述,我們可以瞭解到遞迴演算法的精髓:要從功能上理解函式,同時你要相信你正在寫的函式是正確的,在此基礎上呼叫它,那麼它就是正確的。 下面就從幾個常見的演算法題來看看如何理解遞迴,這是我的一些理解,歡迎大家提出更好的方法。

2.1)漢諾塔問題

題: 漢諾塔問題是個常見問題,就是說有n個大小不等的盤子放在一個塔A上面,自底向上按照從大到小的順序排列。要求將所有n個盤子搬到另一個塔C上面,可以藉助一個塔B中轉,但是要滿足任何時刻大盤子不能放在小盤子上面。

解: 基本思想分三步,先把上面的 N-1 個盤子經 C 移到 B,然後將最底下的盤子移到 C,再將 B 上面的N-1個盤子經 A 移動到 C。總的時間複雜度 f(n)=2f(n-1)+1,所以 f(n)=2^n-1

/**
 * 漢諾塔
 */
void hano(char a, char b, char c, int n) {
    if (n <= 0) return;

    hano(a, c, b, n-1);
    move(a, c);
    hano(b, a, c, n-1);
}

void move(char a, char b)
{
    printf("%c->%c\n", a, b);
}
複製程式碼

2.2)求二叉樹的深度

這裡的深度指的是二叉樹從根結點到葉結點最大的高度,比如只有一個結點,則深度為1,如果有N層,則高度為N。

int depth(BTNode* root)  
{  
    if (root == NULL)  
        return 0;  
    else {  
        int lDepth = depth(root->left);  //獲取左子樹深度  
        int rDepth = depth(root->right); //獲取右子樹深度  
        return lDepth>rDepth? lDepth+1: rDepth+1; //取較大值+1即為二叉樹深度  
    }  
}  
複製程式碼

那麼如何從功能上理解 depth 函式呢?我們可以知道定義該函式的目的就是求二叉樹深度,也就是說我們要是完成了函式 depth,那麼 depth(root) 就能正確返回以 root 為根結點的二叉樹的深度。因此我們的程式碼中 depth(root->left) 返回左子樹的深度,而depth(root->right) 返回右子樹的深度。儘管這個時候我們還沒有寫完 depth 函式,但是我們相信 depth 函式能夠正確完成功能。因此我們得到了 lDepthrDepth,而後通過比較返回較大值加1為二叉樹的深度。

如果不好理解,可以想象在 depth 中呼叫的函式 depth(root->left) 為另外一個同樣名字完成相同功能的函式,這樣就好理解了。注意 Base Case,這裡就是當 root==NULL 時,則深度為0,函式返回0

2.3)判斷二叉樹是否平衡

一顆平衡的二叉樹是指其任意結點的左右子樹深度之差不大於1。判斷一棵二叉樹是否是平衡的,可以使用遞迴演算法來實現。

int isBalanceBTTop2Down(BTNode *root)
{
    if (!root) return 1;

    int leftHeight = btHeight(root->left);
    int rightHeight = btHeight(root->right);
    int hDiff = abs(leftHeight - rightHeight);

    if (hDiff > 1) return 0;

    return isBalanceBTTop2Down(root->left) && isBalanceBTTop2Down(root->right);
}
複製程式碼

該函式的功能定義是二叉樹 root 是平衡二叉樹,即它所有結點的左右子樹深度之差不大於1。首先判斷根結點是否滿足條件,如果不滿足,則直接返回 0。如果滿足,則需要判斷左子樹和右子樹是否都是平衡二叉樹,若都是則返回1,否則0。

2.4)排列演算法

排列演算法也是遞迴的典範,記得當初第一次看時一層層跟程式碼,頭都大了,現在從函式功能上來看確實好理解多了。先看程式碼:

/**
 * 輸出全排列,k為起始位置,n為陣列大小
 */
void permute(int a[], int k, int n)
{
    if (k == n-1) {
        printIntArray(a, n); // 輸出陣列
    } else {
        int i;
        for (i = k; i < n; i++) {
            swapInt(a, i, k); // 交換
            permute(a, k+1, n); // 下一次排列
            swapInt(a, i, k); // 恢復原來的序列
        }
    }
}
複製程式碼

首先明確的是 perm(a, k, n) 函式的功能:輸出陣列 a 從位置 k 開始的所有排列,陣列長度為 n。這樣我們在呼叫程式的時候,呼叫格式為 perm(a, 0, n),即輸出陣列從位置 0 開始的所有排列,也就是該陣列的所有排列。基礎條件是 k==n-1,此時已經到達最後一個元素,一次排列已經完成,直接輸出。否則,從位置k開始的每個元素都與位置k的值交換(包括自己與自己交換),然後進行下一次排列,排列完成後記得恢復原來的序列。

假定陣列a aan na a =3,則程式呼叫 perm(a, 0, 3) 可以如下理解: 第一次交換 0,0,並執行perm(a, 1, 3),執行完再次交換0,0,陣列此時又恢復成初始值。 第二次交換 1,0(注意陣列此時是初始值),並執行perm(a, 1, 3), 執行完再次交換1,0,陣列此時又恢復成初始值。 第三次交換 2,0,並執行perm(a, 1, 3),執行完成後交換2,0,陣列恢復成初始值。

也就是說,從功能上看,首先確定第0個位置,然後呼叫perm(a, 1, 3)輸出從1開始的排列,這樣就可以輸出所有排列。而第0個位置可能的值為a[0], a[1],a[2],這通過交換來保證第0個位置可能出現的值,記得每次交換後要恢復初始值。

如陣列 a={1,2,3},則程式執行輸出結果為:1 2 3 ,1 3 2 ,2 1 3 ,2 3 1 ,3 2 1 ,3 1 2。即先輸出以1為排列第一個值的排列,而後是2和3為第一個值的排列。

2.5)組合演算法

組合演算法也可以用遞迴實現,只是它的原理跟0-1揹包問題類似。即要麼選要麼不選,注意不能選重複的數。完整程式碼如下:

/*
 * 組合主函式,包括選取1到n個數字
 */ 
void combination(int a[], int n)
{
    int *select = (int *)calloc(sizeof(int), n); // select為輔助陣列,用於儲存選取的數
    int k;
    for (k = 1; k <= n; k++) {
        combinationUtil(a, n, 0, k, select);
    }
}

/*
 * 組合工具函式:從陣列a從位置i開始選取k個數
 */
void combinationUtil(int a[], int n, int i, int k, int *select)
{
    if (i > n) return; //位置超出陣列範圍直接返回,否則非法訪問會出段錯誤

    if (k == 0) {  //選取完了,輸出選取的數字
        int j;
        for (j = 0; j < n; j++) {
            if (select[j])
                printf("%d ", a[j]);
        }
        printf("\n");
    } else {
        select[i] = 1;  
        combinationUtil(a, n, i+1, k-1, select); //第i個數字被選取,從後續i+1開始選取k-1個數
        select[i] = 0;
        combinationUtil(a, n, i+1, k, select); //第i個數字不選,則從後續i+1位置開始還要選取k個數
    }
}
複製程式碼

2.6) 逆序列印字串

這個比較簡單,程式碼如下:

void reversePrint(const char *str) 
{
    if (!*str)
        return;

    reversePrint(str + 1);
    putchar(*str);
}
複製程式碼

2.7) 連結串列逆序

連結串列逆序通常我們會用迭代的方式實現,但是如果要顯得特立獨行一點,可以使用遞迴,如下,程式碼請見倉庫的 aslist 目錄。

/**
 * 連結串列逆序,遞迴實現。
 */
ListNode *listReverseRecursive(ListNode *head)
{
    if (!head || !head->next) {
        return head;
    }

    ListNode *reversedHead = listReverseRecursive(head->next);
    head->next->next = head;
    head->next = NULL;
    return reversedHead;
}
複製程式碼

資料結構和演算法面試題系列—揹包問題總結

0.概述

揹包問題包括0-1揹包問題、完全揹包問題、部分揹包問題等多種變種。其中,最簡單的是部分揹包問題,它可以採用貪心法來解決,而其他幾種揹包問題往往需要動態規劃來求解。本文主要來源於《揹包問題九講》,我選擇了比較簡單的0-1揹包問題和完全揹包問題進行彙總。同時給出實現程式碼,如有錯誤,請各位大蝦指正。本文程式碼在 這裡

1.部分揹包問題

部分揹包問題描述: 有 N 件物品和一個容量為 C 的揹包。第 i 件物品的重量是 w[i],價值是 v[i]。求解將哪些物品裝入揹包可使價值總和最大。注意這裡不要求把物品整個裝入,可以只裝入一個物品的部分。

解法: 部分揹包問題常採用貪心演算法來解決,先對每件物品計算其每單位重量價值 v[i]/w[i],然後從具有最大單位價值的物品開始拿,然後拿第二大價值的物品,直到裝滿揹包。按照這種貪心策略拿到的必然是價值總和最大,這個比較簡單,實現程式碼就略去了。

2. 0-1揹包問題

0-1揹包問題描述

有 N 件物品和一個容量為 C 的揹包。第 i 件物品的重量是 w[i],價值是v[i]。求解將哪些物品裝入揹包可使價值總和最大。注意物品只能要麼拿要麼不拿,這也正是 0-1 的意義所在。可以把部分揹包問題看作是拿金粉,而 0-1 揹包問題則是拿金塊,一個可分,一個不可分。

分析

這是最基礎的揹包問題,特點是:每種物品僅有一件,可以選擇放或不放。 用子問題定義狀態:即 f[i][w] 表示前 i 件物品恰放入一個容量為 c 的揹包可以獲得的最大價值。則其狀態轉移方程便是:

f[i][c] = max{f[i-1][c], f[i-1][c-w[i]]+v[i]} 
複製程式碼

這個方程非常重要,基本上所有跟揹包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:將前 i 件物品放入容量為 c 的揹包中 這個子問題,若只考慮第i件物品的策略(放或不放),那麼就可以轉化為一個只牽扯前 i-1 件物品的問題。

  • 如果不放第 i 件物品,那麼問題就轉化為 前 i-1 件物品放入容量為 v 的揹包中,價值為 f[i-1][c]
  • 如果放第i件物品,那麼問題就轉化為 前 i-1 件物品放入剩下的容量為 c-w[i] 的揹包中,此時能獲得的最大價值就是 f[i-1][c-w[i]]再加上通過放入第 i 件物品獲得的價值 v[i]。

優化空間複雜度

以上方法的時間和空間複雜度均為 O(CN),其中時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到 O(N)。 由於在計算 f[i][c] 的時候,我們只需要用到 f[i-1][c]f[i-1][c-w[i]],所以完全可以通過一維陣列儲存它們的值,這裡用到的小技巧就是需要從 c=C...0 開始反推,這樣就能保證在求 f[c] 的時候 f[c-w[i]] 儲存的是 f[i-1][c-w[i]] 的值。注意,這裡不能從 c=0...C 這樣順推,因為這樣會導致 f[c-w[i]] 的值是 f[i][c-w[i]] 而不是 f[i-1][c-w[i]。這裡可以優化下界,其實只需要從 c=C...w[i] 即可,可以避免不需要的計算。虛擬碼如下所示:

for i=0..N-1
    for c=C..w[i]
        f[c]=max{f[c],f[c-w[i]]+v[i]};
複製程式碼

最終實現程式碼如下:

int knap01(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c;

    for (i = 0; i < N; i++) {
        for (c = C; c >= w[i]; c--) {
            f[c] = max(f[c], f[c-w[i]] + v[i]);
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1); // 列印f陣列
    }
    return f[C];
}
複製程式碼

測試結果如下,即在揹包容量為 10 的時候裝第1和第2個物品(索引從0開始),總重量為 4+5=9,最大價值為 5+6=11。

引數:
w = [3, 4, 5] //物品重量列表
v = [4, 5, 6] //物品價值列表
C = 10

結果(列印陣列f,i為選擇的物品索引,c為揹包重量,值為揹包物品價值):
         
i/c 0 1 2 3 4 5 6 7 8 9 10
 0: 0 0 0 4 4 4 4 4 4 4 4 
 1: 0 0 0 4 5 5 5 9 9 9 9 
 2: 0 0 0 4 5 6 6 9 10 11 11 

KNap01 max: 11
複製程式碼

初始化的細節問題

我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 f[0] 為 0 其它 f[1..C] 均設為 -∞,這樣就可以保證最終得到的 f[N] 是一種恰好裝滿揹包的最優解。如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將 f[0..C] 全部設為0。

為什麼呢?可以這樣理解:初始化的 f 陣列事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量為 0 的揹包可能被價值為 0 的東西 “恰好裝滿”,其它容量的揹包均沒有合法的解,屬於未定義的狀態,它們的值就都應該是 -∞ 了。如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的價值為0,所以初始時狀態的值也就全部為0了。

3.完全揹包問題

問題描述

有 N 種物品和一個容量為 C 的揹包,每種物品都有無限件可用。第i種物品的重量是 w[i],價值是v[i]。求解將哪些物品裝入揹包可使這些物品的重量總和不超過揹包容量,且價值總和最大,物品不能只裝部分。

基本思路

這個問題非常類似於0-1揹包問題,所不同的是每種物品有無限件。也就是從每種物品的角度考慮,與它相關的策略已並非取或不取兩種,而是有取0件、取1件、取2件...等很多種。如果仍然按照解01揹包時的思路,令 f[i][c] 表示前 i 種物品恰放入一個容量為 c 的揹包的最大權值。仍然可以按照每種物品不同的策略寫出狀態轉移方程,像這樣:

f[i][c] = max{f[i-1][c-k*w[i]]+ k*w[i]| 0<=k*w[i]<=c }
複製程式碼

這跟0-1揹包問題一樣有O(CN)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態 f[i][c] 的時間是 O(c/w[i]),總的複雜度可以認為是 O(CN*Σ(c/w[i])),是比較大的。實現程式碼如下:

/*
 * 完全揹包問題
 */
int knapComplete(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c, k;
    for (i = 0; i < N; i++) {
        for (c = C; c >= 0; c--) {
            for (k = 0; k <= c/w[i]; k++) {
                f[c] = max(f[c], f[c-k*w[i]] + k*v[i]);
            }
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1);
    }
    return f[C];
}
複製程式碼

使用與0-1揹包問題相同的例子,執行程式結果如下,最大價值為 13,即選取 2個重量3,1個重量4的物品,總價值最高,為 4*2 + 5 = 13

i/c: 0 1 2 3 4 5 6 7 8 9 10
0:   0 0 0 4 4 4 8 8 8 12 12 
1:   0 0 0 4 5 5 8 9 10 12 13 
2:   0 0 0 4 5 6 8 9 10 12 13 

KNapComplete max: 13
複製程式碼

轉換為0-1揹包問題

既然01揹包問題是最基本的揹包問題,那麼我們可以考慮把完全揹包問題轉化為01揹包問題來解。最簡單的想法是,考慮到第i種物品最多選 C/w[i] 件,於是可以把第 i 種物品轉化為 C/w[i] 件費用及價值均不變的物品,然後求解這個01揹包問題。這樣完全沒有改進基本思路的時間複雜度,但這畢竟給了我們將完全揹包問題轉化為01揹包問題的思路:將一種物品拆成多件物品。

更高效的轉化方法是:把第 i 種物品拆成重量為 w[i]*2^k、價值為 w[i]*2^k 的若干件物品,其中 k 滿足 w[i]*2^k<=C。這是二進位制的思想,因為不管最優策略選幾件第 i 種物品,總可以表示成若干個 2^k 件物品的和。這樣把每種物品拆成 O(log C/w[i]) 件物品,是一個很大的改進。但我們有更優的 O(CN) 的演算法。

進一步優化—O(CN)解法

我們可以採用與0-1揹包問題相反的順序遍歷,從而可以得到 O(CN) 的解法,虛擬碼如下:

for i=0..N-1
    for c=w[i]..C
        f[c]=max{f[c],f[c-w[i]]+v[i]};
複製程式碼

這個虛擬碼與0-1揹包虛擬碼只是 C 的迴圈次序不同而已。0-1揹包之所以要按照 v=V..0的逆序來迴圈。這是因為要保證第i次迴圈中的狀態 f[i][c] 是由狀態 f[i-1][c-w[i]] 遞推而來。換句話說,這正是為了保證每件物品只選一次,保證在考慮“選入第i件物品”這件策略時,依據的是一個絕無已經選入第i件物品的子結果 f[i-1][c-w[i]]。而現在完全揹包的特點恰是每種物品可選無限件,所以在考慮“加選一件第i種物品”這種策略時,卻正需要一個可能已選入第i種物品的子結果 f[i][c-w[i]],所以就可以並且必須採用 c=w[i]..C 的順序迴圈。這就是這個簡單的程式為何成立的道理。實現程式碼如下:

/**
 * 完全揹包問題-仿01揹包解法
 */
int knapCompleteLike01(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c;
    for (i = 0; i < N; i++) {
        for (c = w[i]; c <= C; c++) {
            f[c] = max(f[c], f[c-w[i]] + v[i]);
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1);

    }
    return f[C];
}
複製程式碼

資料結構和演算法面試題系列—數字題總結

0.概述

數學是科學之基礎,數字題往往也是被面試玩出花來。數學本身是有趣味的一門學科,前段時間有點不務正業,對數學產生了濃厚的興趣,於是看了幾本數學史論的書,也買了《幾何原本》和《陶哲軒的實分析》,看了部分章節,受益良多,有興趣的朋友可以看看。特別是幾何原本,歐幾里得上千年前的著作,裡面的思考和證明方式真的富有啟發性,老少咸宜。本文先總結下面試題中的數字題,我儘量增加了一些數學方面的證明,如有錯誤,也請指正。本文程式碼都在 這裡

1.找質數問題

題: 寫一個程式,找出前N個質數。比如N為100,則找出前100個質數。

分析: 質數(或者叫素數)指在大於1的自然數中,除了1和該數自身外,無法被其他自然數整除的數,如 2,3,5...。最基本的想法就是對 1 到 N 的每個數進行判斷,如果是質數則輸出。一種改進的方法是不需要對 1 到 N 所有的數都進行判斷,因為除了 2 外的偶數肯定不是質數,而奇數可能是質數,可能不是。然後我們可以跳過2與3的倍數,即對於 6n,6n+1, 6n+2, 6n+3, 6n+4, 6n+5,我們只需要判斷 6n+16n+5 是否是質數即可。

判斷某個數m是否是質數,最基本的方法就是對 2 到 m-1 之間的數整除 m,如果有一個數能夠整除 m,則 m 就不是質數。判斷 m 是否是質數還可以進一步改進,不需要對 2 到 m-1 之間的數全部整除 m,只需要對 2 到 根號m 之間的數整除m就可以。如用 2,3,4,5...根號m 整除 m。其實這還是有浪費,因為如果2不能整除,則2的倍數也不能整除,同理3不能整除則3的倍數也不能整除,因此可以只用2到根號m之間小於根號m的質數去除即可。

解: 預先可得2,3,5為質數,然後跳過2與3的倍數,從7開始,然後判斷11,然後判斷13,再是17...規律就是從5加2,然後加4,然後加2,然後再加4。如此反覆即可,如下圖所示,只需要判斷 7,11,13,17,19,23,25,29... 這些數字。

判斷是否是質數採用改進後的方案,即對2到根號m之間的數整除m來進行判斷。需要注意一點,不能直接用根號m判斷,因為對於某些數字,比如 121 開根號可能是 10.999999,所以最好使用乘法判斷,如程式碼中所示。

/**
 * 找出前N個質數, N > 3
 */
int primeGeneration(int n)
{
    int *prime = (int *)malloc(sizeof(int) * n);
    int gap = 2;            
    int count = 3;
    int maybePrime = 5;
    int i, isPrime;

    /* 注意:2, 3, 5 是質數 */
    prime[0] = 2;
    prime[1] = 3;
    prime[2] = 5;

    while (count < n)  {
         maybePrime += gap;
         gap = 6 - gap;
         isPrime = 1; 
         for (i = 2; prime[i]*prime[i] <= maybePrime && isPrime; i++)
              if (maybePrime % prime[i] == 0)
                   isPrime = 0;

         if (isPrime)
              prime[count++] = maybePrime;
    }

    printf("\nFirst %d Prime Numbers are :\n", count);

    for (i = 0; i < count; i++) {
         if (i % 10 == 0) printf("\n");
         printf("%5d", prime[i]);
    }
    printf("\n");
    return 0;
}
複製程式碼

2.階乘末尾含0問題

題: 給定一個整數N,那麼N的階乘N!末尾有多少個0呢?(該題取自《程式設計之美》)

解1: 流行的解法是,如果 N!= K10M,且K不能被10整除,則 N!末尾有 M 個0。考慮 N!可以進行質因數分解,N!= (2X) * (3Y) * (5Z)..., 則由於10 = 25,所以0的個數只與 XZ 相關,每一對2和5相乘得到一個 10,所以 0 的個數 M = min(X, Z),顯然 2 出現的數目比 5 要多,所以 0 的個數就是 5 出現的個數。由此可以寫出如下程式碼:

/**
 * N!末尾0的個數
 */
int numOfZero(int n)
{
    int cnt = 0, i, j;
    for (i = 1; i <= n; i++) {
        j = i;
        while (j % 5 == 0) {
            cnt++;
            j /= 5;
        }
    }
    return cnt;
}
複製程式碼

解2: 繼續分析可以改進上面的程式碼,為求出1到N的因式分解中有多少個5,令 Z=N/5 + N/(52) + N/(53)+... 即 N/5 表示 1 到 N 的數中 5 的倍數貢獻一個 5,N/(52) 表示 52 的倍數再貢獻一個 5...。舉個簡單的例子,比如求1到100的數因式分解中有多少個5,可以知道5的倍數有20個,25的倍數有4個,所以一共有24個5。程式碼如下:

/**
 * N!末尾0的個數-優化版
 */
int numOfZero2(int n)
{
    int cnt = 0;
    while (n) {
        cnt += n/5;
        n /= 5;
    }
    return cnt;
}
複製程式碼

總結: 上面的分析乏善可陳,不過需要提到的一點就是其中涉及到的一條算術基本定理,也就是 任意大於1的自然數都可以分解為質數的乘積,而且該分解方式是唯一的。 定理證明分為兩個部分,存在性和唯一性。證明如下:

存在性證明

使用反證法來證明,假設存在大於1的自然數不能寫成質數的乘積,把最小的那個稱為n。自然數可以根據其可除性(是否能表示成兩個不是自身的自然數的乘積)分成3類:質數、合數和1。

  • 首先,按照定義,n大於1。
  • 其次,n 不是質數,因為質數p可以寫成質數乘積:p=p,這與假設不相符合。因此n只能是合數,但每個合數都可以分解成兩個嚴格小於自身而大於1的自然數的積。設 n = a*b,a 和 b都是大於1小於n的數,由假設可知,a和b都可以分解為質數的乘積,因此n也可以分解為質數的乘積,所以這與假設矛盾。由此證明所有大於1的自然數都能分解為質數的乘積。

唯一性證明

  • 當n=1的時候,確實只有一種分解。
  • 假設對於自然數 n>1,存在兩種因式分解: n=p1...pm = q1...qk,p1<=...<=pm, q1<=...<=qk,其中 p 和 q 都是質數,我們要證明 p1=q1,p2=q2...如果不相等,我們可以設 p1 < q1,從而 p1 小於所有的 q。由於 p1 和 q1 是質數,所以它們的最大公約數為1,由歐幾里德演算法可知存在整數 a 和 b 使得 a * p1 + b * q1 = 1。因此 a * p1 * q2...qk + b * q1 * q2...qk = q2...qk (等式1)。由於 q1...qk = n,因此等式1左邊是 p1 的整數倍,從而等式1右邊的 q2...qk 也必須是 p1 的整數倍,因此必然有 p1 = qi,i > 1。而這與前面 p1 小於所有的 q 矛盾,因此,由對稱性,對 p1 > q1 這種情況可以得到類似結論,故可以證明 p1 = q1,同理可得 p2 = q2...pm=qk,由此完成唯一性的證明。

3.1-N正整數中1的數目

題: 給定一個十進位制正整數N,求出從 1 到 N 的所有整數中包含 1 的個數。比如給定 N=23,則包含1的個數為13。其中個位出現1的數字有 1,11,21,共3個,十位出現1的數字有 10,11...19 共10個,所以總共包含 1 的個數為 3 + 10 = 13 個。

分析: 最自然的想法莫過於直接遍歷1到N,求出每個數中包含的1的個數,然後將這些個數相加就是總的 1 的個數。需要遍歷 N 個數,每次計算 1 的個數需要 O(log10N),該演算法複雜度為 O(Nlog10N)。當數字N很大的時候,該演算法會耗費很長的時間,應該還有更好的方法。

解: 我們可以從1位數開始分析,慢慢找尋規律。

  • 當 N 為 1 位數時,對於 N>=1,1 的個數 f(N) 為1。

  • 當 N 為 2 位數時,則個位上1的個數不僅與個位數有關,還和十位數字有關。

    • 當 N=23 時,個位上 1 的個數有 1、11、21 共3個,十位上1的個數為 10,11...19 共10個,所以 1 的個數 f(N) = 3+10 = 13。如果 N 的個位數 >=1,則個位出現1的次數為十位數的數字加1;如果 N 的個位數為0,則個位出現 1 的次數等於十位數的數字。
    • 十位數上出現1的次數類似,如果N的十位數字等於1,則十位數上出現1的次數為各位數字加1;如果N的十位數字大於1,則十位數上出現1的次數為10。
  • 當 N 為 3 位數時,同樣分析可得1的個數。如 N=123,可得 1出現次數 = 13+20+24 = 57

  • 當 N 為 4,5...K 位數時,我們假設 N=abcde,則要計算百位上出現1的數目,則它受到三個因素影響:百位上的數字,百位以下的數字,百位以上的數字。

    • 如果百位上數字為0,則百位上出現1的次數為更高位數字決定。如 N=12013,則百位出現1的數字有100~199, 1000~1199, 2100~2199...11100~111999 共 1200 個,等於百位的更高位數字(12)*當前位數(100)。
    • 如果百位上數字為1,則百位上出現1的次數不僅受更高位影響,還受低位影響。如12113,則百位出現1的情況共有 1200+114=1314 個,也就是高位影響的 12 * 100 + 低位影響的 113+1 = 114 個。
    • 如果百位上數字為其他數字,則百位上出現1的次數僅由更高位決定。如 12213,則百位出現1的情況為 (12+1)*100=1300。

有以上分析思路,寫出下面的程式碼。其中 low 表示低位數字,curr 表示當前考慮位的數字,high 表示高位數字。一個簡單的分析,考慮數字 123,則首先考慮個位,則 curr 為 3,低位為 0,高位為 12;然後考慮十位,此時 curr 為 2,低位為 3,高位為 1。其他的數字可以以此類推,實現程式碼如下:

/**
 * 1-N正整數中1的個數
 */
int numOfOne(int n)
{
    int factor = 1, cnt = 0;  //factor為乘數因子
    int low = 0, curr = 0, high = 0;
    while (n / factor != 0) {
        low = n - n/factor * factor;  //low為低位數字,curr為當前考慮位的數字,high為高位數字
        curr = n/factor % 10;
        high = n/(factor * 10);

        switch(curr) {
            case 0:   //當前位為0的情況
                cnt += high * factor;
                break;
            case 1:   //當前位為1的情況
                cnt += high * factor + low + 1;
                break;
            default:  //當前位為其他數字的情況
                cnt += (high+1) * factor;
                break;
        }
        factor *= 10;
    }
    return cnt;
}
複製程式碼

4.和為N的正整數序列

題: 輸入一個正整數數N,輸出所有和為N連續正整數序列。例如輸入 15,由於 1+2+3+4+5=4+5+6=7+8=15,所以輸出 3 個連續序列 1-5、4-6和7-8。

解1:運用數學規律

假定有 k 個連續的正整數和為 N,其中連續序列的第一個數為 x,則有 x+(x+1)+(x+2)+...+(x+k-1) = N。從而可以求得 x = (N - k*(k-1)/2) / k。當 x<=0 時,則說明已經沒有正整數序列的和為 N 了,此時迴圈退出。初始化 k=2,表示2個連續的正整數和為 N,則可以求出 x 的值,並判斷從 x 開始是否存在2個連續正整數和為 N,若不存在則 k++,繼續迴圈。

/**
 * 查詢和為N的連續序列
 */
int findContinuousSequence(int n) 
{
    int found = 0;
    int k = 2, x, m, i;  // k為連續序列的數字的數目,x為起始的值,m用於判斷是否有滿足條件的值。
    while (1) { 
        x = (n - k*(k-1)/2) / k;  // 求出k個連續正整數和為n的起始值x
        m = (n - k*(k-1)/2) % k; // m用於判斷是否有滿足條件的連續正整數值

        if (x <= 0)
            break;    // 退出條件,如果x<=0,則迴圈退出。

        if (!m) {     // m為0,表示找到了連續子序列和為n。
            found = 1;
            printContinuousSequence(x, k);
        }
        k++;
    }
    return found;
}

/**
 * 列印連續子序列
 */
void printContinuousSequence(int x, int k)
{
    int i;
    for (i = 0; i < k; i++) {
        printf("%d ", x++);
    }
    printf("\n");
}
複製程式碼

解2:序列結尾位置法

因為序列至少有兩個數,可以先判定以數字2結束的連續序列和是否有等於 n 的,然後是以3結束的連續序列和是否有等於 n 的,...以此類推。此解法思路參考的何海濤先生的博文,程式碼如下:

/**
 * 查詢和為N的連續序列-序列結尾位置法
 */
int findContinuousSequenceEndIndex(int n) 
{
    if (n < 3) return 0;

    int found = 0;
    int begin = 1, end = 2;
    int mid = (1 + n) / 2;
    int sum = begin + end;

    while (begin < mid) {
        if (sum > n) {
            sum -= begin;
            begin++;
        } else {
            if (sum == n) {
                found = 1;
                printContinuousSequence(begin, end-begin+1);
            }

            end++;
            sum += end;
        }
    }

    return found;
}
複製程式碼

擴充套件: 是不是所有的正整數都能分解為連續正整數序列呢?

答案: 不是。並不是所有的正整數都能分解為連續的正整數和,如 32 就不能分解為連續正整數和。對於奇數,我們總是能寫成 2k+1 的形式,因此可以分解為 [k,k+1],所以總是能分解成連續正整數序列。對於每一個偶數,均可以分解為質因數之積,即 n = 2i * 3j * 5k...,如果除了i之外,j,k...均為0,那麼 n = 2i,對於這種數,其所有的因數均為偶數,是不存在連續子序列和為 n 的,因此除了2的冪之外的所有 n>=3 的正整數均可以寫成一個連續的自然數之和。

5.最大連續子序列和

題: 求取陣列中最大連續子序列和,例如給定陣列為 A = {1, 3, -2, 4, -5}, 則最大連續子序列和為 6,即 1 + 3 +(-2)+ 4 = 6

分析: 最大連續子序列和問題是個很老的面試題了,最佳的解法是 O(N) 複雜度,當然有些值得注意的地方。這裡總結三種常見的解法,重點關注最後一種 O(N) 的解法即可。需要注意的是有些題目中的最大連續子序列和如果為負,則返回0;而本題如果是全為負數,則返回最大的負數即可。

解1: 因為最大連續子序列和只可能從陣列 0 到 n-1 中某個位置開始,我們可以遍歷 0 到 n-1 個位置,計算由這個位置開始的所有連續子序列和中的最大值。最終求出最大值即可。

/**
 * 最大連續子序列和
 */
int maxSumOfContinuousSequence(int a[], int n)
{
    int max = a[0], i, j, sum; // 初始化最大值為第一個元素
    for (i = 0; i < n; i++) {
        sum = 0; // sum必須清零
        for (j = i; j < n; j++) { //從位置i開始計算從i開始的最大連續子序列和的大小,如果大於max,則更新max。
            sum += a[j];
            if (sum > max)
                max = sum;
        }
    }
    return max;
}
複製程式碼

解2: 該問題還可以通過分治法來求解,最大連續子序列和要麼出現在陣列左半部分,要麼出現在陣列右半部分,要麼橫跨左右兩半部分。因此求出這三種情況下的最大值就可以得到最大連續子序列和。

/**
 * 最大連續子序列和-分治法
 */
int maxSumOfContinuousSequenceSub(int a[], int l, int u)
{
    if (l > u) return 0;
    if (l == u) return a[l];
    int m = (l + u) / 2;

    /*求橫跨左右的最大連續子序列左半部分*/
    int lmax = a[m], lsum = 0;
    int i;

    for (i = m; i >= l; i--) {
        lsum += a[i];
        if (lsum > lmax)
            lmax = lsum;
    }

    /*求橫跨左右的最大連續子序列右半部分*/
    int rmax=a[m+1], rsum = 0;
    for (i = m+1; i <= u; i++) {
        rsum += a[i];
        if (rsum > rmax)
            rmax = rsum;
    }

    return max3(lmax+rmax, maxSumOfContinuousSequenceSub(a, l, m),
        maxSumOfContinuousSequenceSub(a, m+1, u)); //返回三者最大值
}
複製程式碼

解3: 還有一種更好的解法,只需要 O(N) 的時間。因為最大 連續子序列和只可能是以位置 0~n-1 中某個位置結尾。當遍歷到第 i 個元素時,判斷在它前面的連續子序列和是否大於0,如果大於0,則以位置 i 結尾的最大連續子序列和為元素 i 和前面的連續子序列和相加;否則,則以位置 i 結尾最大連續子序列和為a[i]。

/**
 * 最打連續子序列和-結束位置法
 */
int maxSumOfContinuousSequenceEndIndex(int a[], int n)
{
    int maxSum, maxHere, i;
    maxSum = maxHere = a[0];   // 初始化最大和為a[0]

    for (i = 1; i < n; i++) {
        if (maxHere <= 0)
            maxHere = a[i];  // 如果前面位置最大連續子序列和小於等於0,則以當前位置i結尾的最大連續子序列和為a[i]
        else
            maxHere += a[i]; // 如果前面位置最大連續子序列和大於0,則以當前位置i結尾的最大連續子序列和為它們兩者之和

        if (maxHere > maxSum) {
            maxSum = maxHere;  //更新最大連續子序列和
        }
    }
    return maxSum;
}
複製程式碼

6.最大連續子序列乘積

題: 給定一個整數序列(可能有正數,0和負數),求它的一個最大連續子序列乘積。比如給定陣列a[] = {3, -4, -5, 6, -2},則最大連續子序列乘積為 360,即 3*(-4)*(-5)*6=360

解: 求最大連續子序列乘積與最大連續子序列和問題有所不同,因為其中有正有負還有可能有0,可以直接利用動歸來求解,考慮到可能存在負數的情況,我們用 max[i] 來表示以 a[i] 結尾的最大連續子序列的乘積值,用 min[i] 表示以 a[i] 結尾的最小的連續子序列的乘積值,那麼狀態轉移方程為:

max[i] = max{a[i], max[i-1]*a[i], min[i-1]*a[i]};
min[i] = min{a[i], max[i-1]*a[i], min[i-1]*a[i]};
複製程式碼

初始狀態為 max[0] = min[0] = a[0]。程式碼如下:

/**
 * 最大連續子序列乘積
 */
int maxMultipleOfContinuousSequence(int a[], int n)
{
    int minSofar, maxSofar, max, i;
    int maxHere, minHere;
    max = minSofar = maxSofar = a[0];

    for(i = 1; i < n; i++){
        int maxHere = max3(maxSofar*a[i], minSofar*a[i], a[i]);
        int minHere = min3(maxSofar*a[i], minSofar*a[i], a[i]);
        maxSofar = maxHere, minSofar = minHere;
        if(max < maxSofar)
            max = maxSofar;
    }
    return max;
}
複製程式碼

7.位元位相關

1) 判斷一個正整數是否是2的整數次冪

題: 給定一個正整數 n,判斷該正整數是否是 2 的整數次冪。

解1: 一個基本的解法是設定 i=1 開始,迴圈乘以2直到 i>=n,然後判斷 i 是否等於 n 即可。

解2: 還有一個更好的方法,那就是觀察數字的二進位制表示,如 n=101000,則我們可以發現n-1=100111。也就是說 n -> n-1 是將 n 最右邊的 1 變成了 0,同時將 n 最右邊的 1 右邊的所有位元位由0變成了1。因此如果 n & (n-1) == 0 就可以判定正整數 n 為 2的整數次冪。

/**
 * 判斷正整數是2的冪次
 */
int powOf2(int n)
{
    assert(n > 0);
    return !(n & (n-1));
}
複製程式碼

2) 求一個整數二進位制表示中1的個數

題: 求整數二進位制表示中1的個數,如給定 N=6,它的二進位制表示為 0110,二進位制表示中1的個數為2。

解1: 一個自然的方法是將N和1按位與,然後將N除以2,直到N為0為止。該演算法程式碼如下:


int numOfBit1(int n)
{
    int cnt = 0;
    while (n) {
        if (n & 1)
            ++cnt;
        n >>= 1;
    }
    return cnt;
}
複製程式碼

上面的程式碼存在一個問題,如果輸入為負數的話,會導致死迴圈,為了解決負數的問題,如果使用的是JAVA,可以直接使用無符號右移操作符 >>> 來解決,如果是在C/C++裡面,為了避免死迴圈,我們可以不右移輸入的數字i。首先 i & 1,判斷i的最低位是不是為1。接著把1左移一位得到2,再和i做與運算,就能判斷i的次高位是不是1...,這樣反覆左移,每次都能判斷i的其中一位是不是1。

/**
 * 二進位制表示中1的個數-解法1
 */
int numOfBit1(int n)
{
    int cnt = 0;
    unsigned int flag = 1;
    while (flag) {
        if(n & flag)
            cnt++;

        flag = flag << 1;
        if (flag > n)
            break;
    }
    return cnt;
}
複製程式碼

解2: 一個更好的解法是採用第一個題中類似的思路,每次 n&(n-1)就能把n中最右邊的1變為0,所以很容易求的1的總數目。

/**
 * 二進位制表示中1的個數-解法2
 */
int numOfBit1WithCheck(int n)
{
    int cnt = 0;
    while (n != 0) {
        n = (n & (n-1));
        cnt++;
    }
    return cnt;
}
複製程式碼

3) 反轉一個無符號整數的所有位元位

題: 給定一個無符號整數N,反轉該整數的所有位元位。例如有一個 8 位的數字 01101001,則反轉後變成 10010110,32 位的無符號整數的反轉與之類似。

解1: 自然的解法就是參照字串反轉的演算法,假設無符號整數有n位,先將第0位和第n位交換,然後是第1位和第n-1位交換...注意一點就是交換兩個位是可以通過異或操作 XOR 來實現的,因為 0 XOR 0 = 0, 1 XOR 1 = 0, 0 XOR 1 = 1, 1 XOR 0 = 1,使用 1 異或 0/1 會讓其值反轉。

/**
 * 反轉位元位
 */
uint swapBits(uint x, uint i, uint j)
{
    uint lo = ((x >> i) & 1);  // 取x的第i位的值
    uint hi = ((x >> j) & 1);  // 取x的第j位的值
    if (lo ^ hi) {             
        x ^= ((1U << i) | (1U << j)); // 如果第i位和第j位值不同,則交換i和j這兩個位的值,使用異或操作實現
    }
    return x;
}
 
/**
 * 反轉整數位元位-仿字串反轉
 */
uint reverseXOR(uint x)
{
    uint n = sizeof(x) * 8;
    uint i;
    for (i = 0; i < n/2; i++) {
        x = swapBits(x, i, n-i-1);
    }
    return x;
}
複製程式碼

解2: 採用分治策略,首先交換數字x的相鄰位,然後再是 2 個位交換,然後是 4 個位交換...比如給定一個 8 位數 01101010,則首先交換相鄰位,變成 10 01 01 01,然後交換相鄰的 2 個位,得到 01 10 01 01,然後再是 4 個位交換,得到 0101 0110。總的時間複雜度為 O(lgN),其中 N 為整數的位數。下面給出一個反轉32位整數的程式碼,如果整數是64位的可以以此類推。

/**
 * 反轉整數位元位-分治法
 */
uint reverseMask(uint x)
{
    assert(sizeof(x) == 4); // special case: only works for 4 bytes (32 bits).
    x = ((x & 0x55555555) << 1) | ((x & 0xAAAAAAAA) >> 1);
    x = ((x & 0x33333333) << 2) | ((x & 0xCCCCCCCC) >> 2);
    x = ((x & 0x0F0F0F0F) << 4) | ((x & 0xF0F0F0F0) >> 4);
    x = ((x & 0x00FF00FF) << 8) | ((x & 0xFF00FF00) >> 8);
    x = ((x & 0x0000FFFF) << 16) | ((x & 0xFFFF0000) >> 16);
    return x;
}
複製程式碼

系列文章目錄

其他

此外,在我 簡書的部落格 上還整理有《docker相關技術筆記》、《MIT6.828作業系統學習筆記》、《python原始碼剖析筆記》等文章,請大家指正。

參考資料

我在參加掘金秋招求職徵文活動,活動詳情秋招求職時,寫文就有好禮相送 | 掘金技術徵文

那些年,面試中常見的資料結構基礎和演算法題(下) | 掘金技術徵文

相關文章