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

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

0 概述

數學是科學之基礎,數字題每每也是被面試玩出花來。數學自己是有趣味的一門學科,前段時間有點遊手好閒,對數學產生了濃厚的興趣,因而看了幾本數學史論的書,也買了《幾何本來》和《陶哲軒的實分析》,看了部分章節,受益良多,有興趣的朋友能夠看看。特別是幾何本來,歐幾里得上千年前的著做,裏面的思考和證實方式真的富有啓發性,老小咸宜。本文先總結下面試題中的數字題,我儘可能增長了一些數學方面的證實,若有錯誤,也請指正。本文代碼都在 這裏github

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,因此最好使用乘法判斷,如代碼中所示。bash

/**
 * 找出前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;
}
複製代碼

參考資料

相關文章
相關標籤/搜索