1. 程式人生 > >程式設計師程式設計藝術-----第五章-----尋找滿足和為定值的兩個或多個數

程式設計師程式設計藝術-----第五章-----尋找滿足和為定值的兩個或多個數

                    程式設計師程式設計藝術:第五章、尋找和為定值的兩個或多個數
 

前奏

    希望此程式設計藝術系列能給各位帶來的是一種方法,一種創造力,一種舉一反三的能力。本章依然同第四章一樣,選取比較簡單的面試題,恭祝各位旅途愉快。同樣,有任何問題,歡迎不吝指正。謝謝。


第一節、尋找和為定值的兩個數
第14題(陣列):
題目:輸入一個數組和一個數字,在陣列中查詢兩個數,使得它們的和正好是輸入的那個數字。
要求時間複雜度是O(n)。如果有多對數字的和等於輸入的數字,輸出任意一對即可。
例如輸入陣列1、2、4、7、11、15和數字15。由於4+11=15,因此輸出4和11。

分析

咱們試著一步一步解決這個問題(注意闡述中數列有序無序的區別

):

  1. 直接窮舉,從陣列中任意選取兩個數,判定它們的和是否為輸入的那個數字。此舉複雜度為O(N^2)。很顯然,我們要尋找效率更高的解法。
  2. 題目相當於,對每個a[i],然後查詢判斷sum-a[i]是否也在原始序列中,每一次要查詢的時間都要花費為O(N),這樣下來,最終找到兩個數還是需要O(N^2)的複雜度。那如何提高查詢判斷的速度列?對了,二分查詢,將原來O(N)的查詢時間提高到O(logN),這樣對於N個a[i],都要花logN的時間去查詢相對應的sum-a[i]是否在原始序列中,總的時間複雜度已降為O(N*logN),且空間複雜度為O(1)。(如果有序,直接二分O(N*logN),如果無序,先排序後二分,複雜度同樣為O(N*logN+N*logN)=O(N*logN
    ),空間總為O(1))。
  3. 有沒有更好的辦法列?咱們可以依據上述思路2的思想,a[i]在序列中,如果a[i]+a[k]=sum的話,那麼sum-a[i](a[k])也必然在序列中,,舉個例子,如下:
    原始序列:1、 2、 4、 7、11、15     用輸入數字15減一下各個數,得到對應的序列為:
    對應序列:14、13、11、8、4、 0      
    第一個陣列以一指標i 從陣列最左端開始向右掃描,第二個陣列以一指標j 從陣列最右端開始向左掃描,如果下面出現了和上面一樣的數,即a[*i]=a[*j],就找出這倆個數來了。如上,i,j最終在第一個,和第二個序列中找到了相同的數4和11,,所以符合條件的兩個數,即為4+11=15。怎麼樣,兩端同時查詢,時間複雜度瞬間縮短到了O(N),但卻同時需要O(N)的空間儲存第二個陣列(@飛羽:要達到O(N)的複雜度,第一個陣列以一指標i 從陣列最左端開始向右掃描,第二個陣列以一指標j 從陣列最右端開始向左掃描,首先初始i指向元素1,j指向元素0,誰指的元素小,誰先移動,由於1(i)>0(j),所以i不動,j向左移動。然後j移動到元素4發現大於元素1,故而停止移動j,開始移動i,直到i指向4,這時,i指向的元素與j指向的元素相等,故而判斷4是滿足條件的第一個數;然後同時移動i,j再進行判斷,直到它們到達邊界
    )。
  4. 當然,你還可以構造hash表,正如程式設計之美上的所述,給定一個數字,根據hash對映查詢另一個數字是否也在陣列中,只需用O(1)的時間,這樣的話,總體的演算法通上述思路3 一樣,也能降到O(N),但有個缺陷,就是構造hash額外增加了O(N)的空間,此點同上述思路 3。不過,空間換時間,仍不失為在時間要求較嚴格的情況下的一種好辦法。
  5. 如果陣列是無序的,先排序(n*logn),然後用兩個指標i,j,各自指向陣列的首尾兩端,令i=0,j=n-1,然後i++,j--,逐次判斷a[i]+a[j]?=sum,如果某一刻a[i]+a[j]>sum,則要想辦法讓sum的值減小,所以此刻i不動,j--,如果某一刻a[i]+a[j]<sum,則要想辦法讓sum的值增大,所以此刻i++,j不動。所以,陣列無序的時候,時間複雜度最終為O(n*logn+n)=O(n*logn),若原陣列是有序的,則不需要事先的排序,直接O(n)搞定,且空間複雜度還是O(1),此思路是相對於上述所有思路的一種改進。(如果有序,直接兩個指標兩端掃描,時間O(N),如果無序,先排序後兩端掃描,時間O(N*logN+N)=O(N*logN),空間始終都為O(1))。(與上述思路2相比,排序後的時間開銷由之前的二分的n*logn降到了掃描的O(N))。

總結

  • 不論原序列是有序還是無序,解決這類題有以下三種辦法:1、二分(若無序,先排序後二分),時間複雜度總為O(n*logn),空間複雜度為O(1);2、掃描一遍X-S[i]  對映到一個數組或構造hash表,時間複雜度為O(n),空間複雜度為O(n);3、兩個指標兩端掃描(若無序,先排序後掃描),時間複雜度最後為:有序O(n),無序O(n*logn+n)=O(n*logn),空間複雜度都為O(1)。
  • 所以,要想達到時間O(N),空間O(1)的目標,除非原陣列是有序的(指標掃描法),不然,當陣列無序的話,就只能先排序,後指標掃描法或二分(時間n*logn,空間O(1)),或對映或hash(時間O(n),空間O(n))。時間或空間,必須犧牲一個,自個權衡吧。
  • 綜上,若是陣列有序的情況下,優先考慮兩個指標兩端掃描法,以達到最佳的時(O(N)),空(O(1))效應。否則,如果要排序的話,時間複雜度最快當然是隻能達到N*logN,空間O(1)則是不在話下。

程式碼:

ok,在進入第二節之前,咱們先來實現思路5(這裡假定陣列已經是有序的),程式碼可以如下編寫(兩段程式碼實現):

  1. //程式碼一  
  2. //O(N)  
  3. Pair findSum(int *s,int n,int x)     
  4. {     
  5.     //sort(s,s+n);   如果陣列非有序的,那就事先排好序O(N*logN)     
  6.     int *begin=s;     
  7.     int *end=s+n-1;     
  8.     while(begin<end)    //倆頭夾逼,或稱兩個指標兩端掃描法,很經典的方法,O(N)    
  9.     {     
  10.         if(*begin+*end>x)     
  11.         {     
  12.             --end;     
  13.         }     
  14.         else if(*begin+*end<x)     
  15.         {     
  16.             ++begin;     
  17.         }     
  18.         else    
  19.         {     
  20.             return Pair(*begin,*end);     
  21.         }     
  22.     }     
  23.     return Pair(-1,-1);     
  24. }     
  25. //或者如下編寫,  
  26. //程式碼二  
  27. //[email protected] zhedahht && yansha  
  28. //July、updated,2011.05.14。  
  29. bool find_num(int data[], unsigned int length, int sum, int& first_num, int& second_num)  
  30. {     
  31.     if(length < 1)  
  32.         return true;  
  33.     int begin = 0;  
  34.     int end = length - 1;  
  35.     while(end > begin)  
  36.     {  
  37.         long current_sum = data[begin] + data[end];  
  38.         if(current_sum == sum)  
  39.         {  
  40.             first_num = data[begin];  
  41.             second_num = data[end];  
  42.             return true;  
  43.         }  
  44.         else if(current_sum > sum)  
  45.             end--;  
  46.         else  
  47.             begin++;  
  48.     }  
  49.     return false;  
  50. }  

擴充套件:
1、如果在返回找到的兩個數的同時,還要求你返回這兩個數的位置列?
2、如果把題目中的要你尋找的兩個數改為“多個數”,或任意個數列?(請看下面第二節)
3、二分查詢時: left <= right,right = middle - 1;left < right,right = middle;

//演算法所操作的區間,是左閉右開區間,還是左閉右閉區間,這個區間,需要在迴圈初始化,
//迴圈體是否終止的判斷中,以及每次修改left,right區間值這三個地方保持一致,否則就可能出錯.

//二分查詢實現一
int search(int array[], int n, int v)
{
    int left, right, middle;
 
    left = 0, right = n - 1;
 
    while (left <= right)
    {
        middle = left + (right-left)/2;   
        if (array[middle] > v)
        {
            right = middle - 1;
        }
        else if (array[middle] < v)
        {
            left = middle + 1;
        }
        else
        {
            return middle;
        }
    }
 
    return -1;
}

//二分查詢實現二
int search(int array[], int n, int v)
{
    int left, right, middle;
 
    left = 0, right = n;
 
    while (left < right)
    {
        middle = left + (right-left)/2;    
  
        if (array[middle] > v)
        {
            right = middle;
        }
        else if (array[middle] < v)
        {
            left = middle + 1;
        }
        else
        {
            return middle;
        }
    }
 
    return -1;
}


第二節、尋找和為定值的多個數
第21題(陣列)
2010年中興面試題
程式設計求解:
輸入兩個整數 n 和 m,從數列1,2,3.......n 中 隨意取幾個數,
使其和等於 m ,要求將其中所有的可能組合列出來。

解法一
我想,稍後給出的程式已經足夠清楚了,就是要注意到放n,和不放n個區別,即可,程式碼如下:

  1. // 21題遞迴方法  
  2. //[email protected] July && yansha  
  3. //July、yansha,updated。  
  4. #include<list>  
  5. #include<iostream>  
  6. using namespace std;  
  7. list<int>list1;  
  8. void find_factor(int sum, int n)   
  9. {  
  10.     // 遞迴出口  
  11.     if(n <= 0 || sum <= 0)  
  12.         return;  
  13.     // 輸出找到的結果  
  14.     if(sum == n)  
  15.     {  
  16.         // 反轉list  
  17.         list1.reverse();  
  18.         for(list<int>::iterator iter = list1.begin(); iter != list1.end(); iter++)  
  19.             cout << *iter << " + ";  
  20.         cout << n << endl;  
  21.         list1.reverse();      
  22.     }  
  23.     list1.push_front(n);      //典型的01揹包問題  
  24.     find_factor(sum-n, n-1);   //放n,n-1個數填滿sum-n  
  25.     list1.pop_front();  
  26.     find_factor(sum, n-1);     //不放n,n-1個數填滿sum   
  27. }  
  28. int main()  
  29. {  
  30.     int sum, n;  
  31.     cout << "請輸入你要等於多少的數值sum:" << endl;  
  32.     cin >> sum;  
  33.     cout << "請輸入你要從1.....n數列中取值的n:" << endl;  
  34.     cin >> n;  
  35.     cout << "所有可能的序列,如下:" << endl;  
  36.     find_factor(sum,n);  
  37.     return 0;  
  38. }  

解法二
@zhouzhenren:
這個問題屬於子集和問題(也是揹包問題)。本程式採用 回溯法+剪枝
X陣列是解向量,t=∑(1,..,k-1)Wi*Xi, r=∑(k,..,n)Wi
若t+Wk+W(k+1)<=M,則Xk=true,遞迴左兒子(X1,X2,..,X(k-1),1);否則剪枝;
若t+r-Wk>=M && t+W(k+1)<=M,則置Xk=0,遞迴右兒子(X1,X2,..,X(k-1),0);否則剪枝;
本題中W陣列就是(1,2,..,n),所以直接用k代替WK值。

程式碼編寫如下:

  1. //[email protected] 2011 zhouzhenren  
  2. //輸入兩個整數 n 和 m,從數列1,2,3.......n 中 隨意取幾個數,  
  3. //使其和等於 m ,要求將其中所有的可能組合列出來。  
  4. #include <stdio.h>  
  5. #include <stdlib.h>  
  6. #include <memory.h>  
  7. /**  
  8.  * 輸入t, r, 嘗試Wk 
  9.  */  
  10. void sumofsub(int t, int k ,int r, int& M, bool& flag, bool* X)  
  11. {  
  12.     X[k] = true;   // 選第k個數  
  13.     if (t + k == M) // 若找到一個和為M,則設定解向量的標誌位,輸出解  
  14.     {  
  15.         flag = true;  
  16.         for (int i = 1; i <= k; ++i)  
  17.         {  
  18.             if (X[i] == 1)  
  19.             {  
  20.                 printf("%d ", i);  
  21.             }  
  22.         }  
  23.         printf("/n");  
  24.     }  
  25.     else  
  26.     {   // 若第k+1個數滿足條件,則遞迴左子樹  
  27.         if (t + k + (k+1) <= M)  
  28.         {  
  29.             sumofsub(t + k, k + 1, r - k, M, flag, X);  
  30.         }  
  31.         // 若不選第k個數,選第k+1個數滿足條件,則遞迴右子樹  
  32.         if ((t + r - k >= M) && (t + (k+1) <= M))  
  33.         {  
  34.             X[k] = false;  
  35.             sumofsub(t, k + 1, r - k, M, flag, X);  
  36.         }  
  37.     }  
  38. }  
  39. void search(int& N, int& M)  
  40. {  
  41.     // 初始化解空間  
  42.     bool* X = (bool*)malloc(sizeof(bool) * (N+1));  
  43.     memset(X, falsesizeof(bool) * (N+1));  
  44.     int sum = (N + 1) * N * 0.5f;  
  45.     if (1 > M || sum < M) // 預先排除無解情況  
  46.     {  
  47.         printf("not found/n");  
  48.         return;  
  49.     }  
  50.     bool f = false;  
  51.     sumofsub(0, 1, sum, M, f, X);  
  52.     if (!f)  
  53.     {  
  54.         printf("not found/n");  
  55.     }  
  56.     free(X);  
  57. }  
  58. int main()  
  59. {  
  60.     int N, M;  
  61.     printf("請輸入整數N和M/n");  
  62.     scanf("%d%d", &N, &M);  
  63.     search(N, M);  
  64.     return 0;  
  65. }  

擴充套件:

1、從一列數中篩除儘可能少的數使得從左往右看,這些數是從小到大再從大到小的(網易)。

2、有兩個序列a,b,大小都為n,序列元素的值任意整數,無序;
要求:通過交換a,b中的元素,使[序列a元素的和]與[序列b元素的和]之間的差最小。
例如:  
var a=[100,99,98,1,2, 3];
var b=[1, 2, 3, 4,5,40];(微軟100題第32題)。

    @well:[fairywell]:
給出擴充套件問題 1 的一個解法:
1、從一列數中篩除儘可能少的數使得從左往右看,這些數是從小到大再從大到小的(網易)。
雙端 LIS 問題,用 DP 的思想可解,目標規劃函式 max{ b[i] + c[i] - 1 }, 其中 b[i] 為從左到右, 0 ~ i 個數之間滿足遞增的數字個數; c[i] 為從右到左, n-1 ~ i 個數之間滿足遞增的數字個數。最後結果為 n - max + 1。其中 DP 的時候,可以維護一個 inc[] 陣列表示遞增數字序列,inc[i] 為從小到大第 i 大的數字,然後在計算 b[i] c[i] 的時候使用二分查詢在 inc[] 中找出區間 inc[0] ~ inc[i-1] 中小於 a[i] 的元素個數(low)。
原始碼如下:

  1. /** 
  2. * The problem: 
  3. * 從一列數中篩除儘可能少的數使得從左往右看,這些數是從小到大再從大到小的(網易)。 
  4. * use binary search, perhaps you should compile it with -std=c99 
  5. * fairywell 2011 
  6. */  
  7. #include <stdio.h>  
  8. #define MAX_NUM    (1U<<31)  
  9. int  
  10. main()  
  11. {  
  12.     int i, n, low, high, mid, max;  
  13.     printf("Input how many numbers there are: ");  
  14.     scanf("%d/n", &n);  
  15.     /* a[] holds the numbers, b[i] holds the number of increasing numbers 
  16.     * from a[0] to a[i], c[i] holds the number of increasing numbers 
  17.     * from a[n-1] to a[i] 
  18.     * inc[] holds the increasing numbers 
  19.     * VLA needs c99 features, compile with -stc=c99 
  20.     */  
  21.     double a[n], b[n], c[n], inc[n];  
  22.     printf("Please input the numbers:/n");  
  23.     for (i = 0; i < n; ++i) scanf("%lf", &a[i]);  
  24.     // update array b from left to right  
  25.     for (i = 0; i < n; ++i) inc[i] = (unsigned) MAX_NUM;  
  26.     //b[0] = 0;  
  27.     for (i = 0; i < n; ++i) {  
  28.         low = 0; high = i;  
  29.         while (low < high) {  
  30.             mid = low + (high-low)*0.5;  
  31.             if (inc[mid] < a[i]) low = mid + 1;  
  32.             else high = mid;  
  33.         }  
  34.         b[i] = low + 1;  
  35.         inc[low] = a[i];  
  36.     }  
  37.     // update array c from right to left  
  38.     for (i = 0; i < n; ++i) inc[i] = (unsigned) MAX_NUM;  
  39.     //c[0] = 0;  
  40.     for (i = n-1; i >= 0; --i) {  
  41.         low = 0; high = i;  
  42.         while (low < high) {  
  43.             mid = low + (high-low)*0.5;  
  44.             if (inc[mid] < a[i]) low = mid + 1;  
  45.             else high = mid;  
  46.         }  
  47.         c[i] = low + 1;  
  48.         inc[low] = a[i];  
  49.     }  
  50.     max = 0;  
  51.     for (i = 0; i < n; ++i )  
  52.         if (b[i]+c[i] > max) max = b[i] + c[i];  
  53.         printf("%d number(s) should be erased at least./n", n+1-max);  
  54.         return 0;  
  55. }  

@yansha:fairywell的程式很贊,時間複雜度O(nlogn),這也是我能想到的時間複雜度最優值了。不知能不能達到O(n)。

擴充套件題第2題

當前陣列a和陣列b的和之差為
    A = sum(a) - sum(b)

a的第i個元素和b的第j個元素交換後,a和b的和之差為
    A' = sum(a) - a[i] + b[j] - (sum(b) - b[j] + a[i])
           = sum(a) - sum(b) - 2 (a[i] - b[j])
           = A - 2 (a[i] - b[j])

設x = a[i] - b[j],得
    |A| - |A'| = |A| - |A-2x|

    假設A > 0,

    當x 在 (0,A)之間時,做這樣的交換才能使得交換後的a和b的和之差變小,x越接近A/2效果越好,
    如果找不到在(0,A)之間的x,則當前的a和b就是答案。

所以演算法大概如下:
    在a和b中尋找使得x在(0,A)之間並且最接近A/2的i和j,交換相應的i和j元素,重新計算A後,重複前面的步驟直至找不到(0,A)之間的x為止。 

接上,@yuan:
a[i]-b[j]要接近A/2,則可以這樣想,
我們可以對於a陣列的任意一個a[k],在陣列b中找出與a[k]-C最接近的數(C就是常數,也就是0.5*A)
這個數要麼就是a[k]-C,要麼就是比他稍大,要麼比他稍小,所以可以要二分查詢。

查詢最後一個小於等於a[k]-C的數和第一個大於等於a[k]-C的數,
然後看哪一個與a[k]-C更加接近,所以T(n) = nlogn。

本章完。

程式設計師面試題狂想曲-tctop(the crazy thinking of programers)的修訂wiki(http://tctop.wikispaces.com/)已建立,我們急切的想得到讀者的反饋,意見,建議,以及更好的思路,演算法,和程式碼優化的建議。所以,

)中的錯誤,問題,與漏洞,歡迎告知給我們,我們將感激不盡,同時,免費贈送本blog內的全部博文集錦的CHM檔案1期;
•如果你能對狂想曲系列的創作提供任何建設性意見,或指導,歡迎反饋給我們,並真誠邀請您加入到狂想曲的wiki修訂工作中;
•如果你是程式設計高手,對狂想曲的任何一章有自己更好的思路,或演算法,歡迎加入狂想曲的創作組,以為千千萬萬的讀者創造更多的價值,更好的服務。
Ps:狂想曲tctop的wiki修訂地址為:http://tctop.wikispaces.com/
。歡迎圍觀,更歡迎您加入到狂想曲的創作或wiki修訂中。 

版權所有,本人對本blog內所有任何內容享有版權及著作權。實要轉載,請以連結形式註明出處。