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

前言

這是 數據結構和算法面試題系列的下半部分,這部分主要是算法類 包括二分查找、排序算法、遞歸算法、隨機算法、揹包問題、數字問題等算法相關內容。本系列完整代碼在 github 建了個倉庫,全部代碼都從新整理和作了一些基本的測試,代碼倉庫地址在這裏: shishujuan/dsalg: 數據結構與算法系列彙總,若有錯誤,請在文章下面評論指出或者在 github 給我留言,我好及時改正以避免誤導其餘朋友。html

文章末尾有系列目錄,能夠按需取閱,若是須要測試,亦能夠將倉庫代碼 clone 下來進行各類測試。若有錯誤或者引用不全、有侵權的地方,請你們給我指出,我好及時調整改正。若是本系列有幫助到你,也歡迎點贊或者在 github 上 star✨✨,十分感謝。python

數據結構和算法面試題系列—二分查找算法詳解

0.概述

二分查找自己是個簡單的算法,可是正是由於其簡單,更容易寫錯。甚至於在二分查找算法剛出現的時候,也是存在 bug 的(溢出的 bug),這個 bug 直到幾十年後才修復(見《編程珠璣》)。本文打算對二分查找算法進行總結,並對由二分查找引伸出來的問題進行分析和彙總。如有錯誤,請指正。本文完整代碼在 這裏linux

1.二分查找基礎

相信你們都知道二分查找的基本算法,以下所示,這就是二分查找算法代碼:c++

/**
 * 基本二分查找算法
 */
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)git

這裏須要解釋下爲何 t 不存在數組中時不是返回 -1 而要返回 -(l+1)。首先咱們能夠觀察 l 的值,若是查找不成功,則 l 的值剛好是 t 應該在數組中插入的位置。github

舉個例子,假定有序數組 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。這個問題在《編程珠璣》第九章有很好的分析,這裏就直接用了。算法的精髓在於循環不變式的巧妙設計,代碼以下:docker

/**
 * 二分查找第一次出現位置
 */
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返回。該算法的效率雖然解決了更爲複雜的問題,可是其效率比初始版本的二分查找還要高,由於它在每次循環中只須要比較一次,前一程序則一般須要比較兩次。shell

舉個例子:對於數組 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 的個數有 一、十一、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-五、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源碼剖析筆記》等文章,請你們指正。

參考資料

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

相關文章
相關標籤/搜索