1. 程式人生 > >(2017)第八屆藍橋杯大賽個人賽省賽(軟體類) C/C++ 大學A組 題解(第八題)

(2017)第八屆藍橋杯大賽個人賽省賽(軟體類) C/C++ 大學A組 題解(第八題)

前言

完成了回貴州的老家之行,也該回學校啦=w=

這次的題目是我個人認為最棘手的一道題,當然從表面上看這道題還是很容易的

第八題

題目

標題:包子湊數
小明幾乎每天早晨都會在一家包子鋪吃早餐。他發現這家包子鋪有N種蒸籠,其中第i種蒸籠恰好能放Ai個包子。每種蒸籠都有非常多籠,可以認為是無限籠。
每當有顧客想買X個包子,賣包子的大叔就會迅速選出若干籠包子來,使得這若干籠中恰好一共有X個包子。比如一共有3種蒸籠,分別能放3、4和5個包子。當顧客想買11個包子時,大叔就會選2籠3個的再加1籠5個的(也可能選出1籠3個的再加2籠4個的)。
當然有時包子大叔無論如何也湊不出顧客想買的數量。比如一共有3種蒸籠,分別能放4、5和6個包子。而顧客想買7個包子時,大叔就湊不出來了。
小明想知道一共有多少種數目是包子大叔湊不出來的。
輸入
----
第一行包含一個整數N。(1 <= N <= 100)
以下N行每行包含一個整數Ai。(1 <= Ai <= 100)  
輸出
----
一個整數代表答案。如果湊不出的數目有無限多個,輸出INF。
例如,
輸入:
2  
4  
5   
程式應該輸出:
6  
再例如,
輸入:
2  
4  
6    
程式應該輸出:
INF
樣例解釋:
對於樣例1,湊不出的數目包括:1, 2, 3, 6, 7, 11。  
對於樣例2,所有奇數都湊不出來,所以有無限多個。  
資源約定:
峰值記憶體消耗(含虛擬機器) < 256M
CPU消耗  < 1000ms
請嚴格按要求輸出,不要畫蛇添足地列印類似:“請您輸入...” 的多餘內容。
注意:
main函式需要返回0;
只使用ANSI C/ANSI C++ 標準;
不要呼叫依賴於編譯環境或作業系統的特殊函式。
所有依賴的函式必須明確地在原始檔中 #include <xxx>
不能通過工程設定而省略常用標頭檔案。

提交程式時,注意選擇所期望的語言型別和編譯器型別。

第一種做法

分析

這道題如果要直接分析計算有多少個數無法被湊出來,似乎非常難。

那麼能不能求出哪些數是無法被湊出來的呢?也很難。

但是哪些數能被湊出來是可以通過給定的數去用程式硬湊的。

這樣,我們就可以去判斷一個數能不能被湊出來了。

那我們就先實現一個吧。

那麼這個數可以被湊出來要符合哪些條件呢?

1.這個數是給定的n個數之一。

2.這個數減去給定的n個數中的其中一個之後,依然可以被湊出來。

第1條顯然成立,第2條我們稍作思考也會發現是對的,而且這兩條包含了所有的情況。

當然,這種做法無法解決湊不出來的數是否無限。

程式碼及執行結果

#include <cstdio>
using namespace std;
const int MAX_N = 1e2 + 5;
int n, a[MAX_N];
bool judge(int x) {
    if (x <= 0) return false;
    for (int i = 0; i < n; i++) {
        if (x == a[i]) return true;
        if (judge(x - a[i])) return true;
    }
    return false;
}
int main() {
    int ans = 0;
    scanf("%d", &n);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    for (int i = 1; i <= 1000; i++) {
        if (!judge(i)) ans++;
    }
    printf("%d\n", ans);
    return 0;
}


觀察程式碼我們可以發現,這段判斷函式其實就是一個深搜。

在不考慮無限多個數無法湊出來的情況下,這種做法看上去並沒有什麼問題。

但是我們仔細分析一下時間複雜度,對於每一個數,都有n種可能進入另外一個數,那麼在數很大的情況下,計算每一個數的時間複雜度就會呈指數級增長。


還有一個問題就是,在這段程式碼中,我們預設只要判斷到1000就足矣。但事實果真如此嗎?

現在我們來著手解決這些問題。

第二種解法

分析

先考慮時間複雜度的問題。

我們會發現在從1計算到1000的過程中,每一個數都只考慮比它小的數的情況,不需要考慮比它大的數的情況。那麼,我們在之前既然已經解決了這些比它小的數的情況,何不把它們記下來呢?這樣對於每一個數,我們最多就只需要執行n次判斷了。

那麼這個時候我們再來考慮判斷的範圍。題目限制1s,也就是1e8次運算。那麼每個數最多執行n次的情況下,保險起見我們也可以算到5e5個數。雖然我們無法確定這個範圍是否足夠,但是比1000就要保險得多。

程式碼及執行結果

#include <cstdio>
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 5e5 + 5;
int n, a[MAX_N];
bool able[MAX_M];
// 下面這段程式碼通常被稱為記憶化搜尋
bool judge(int x) {
    if (x <= 0) return false;
    if (able[x]) return true;
    for (int i = 0; i < n; i++) {
        if (x == a[i]) return able[x] = true;
        if (able[x - a[i]]) return able[x] = true;
    }
    return false;
}
int main() {
    int ans = 0;
    scanf("%d", &n);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    for (int i = 1; i <= 5e5; i++) {
        if (!judge(i)) ans++;
    }
    printf("%d\n", ans);
    return 0;
}

之所以被稱為記憶化搜尋,是因為它在搜尋的過程中記錄了所有的結果。

這樣做的好處是,如果每次搜尋都是基於前面搜尋的結果得出的話,效率就會被大大提高。

相較而言,這種做法的答案更大(從而更準確),速度也更快。

但是,我們依舊沒有從根本解決之前所述的幾個問題。當然,如果時間不允許你深入思考,做到這裡也是可以的。

第三種做法

分析

現在,我們來嘗試進一步優化程式。

首先考慮INF的情況。

樣例提出的INF是由於給出的數都是偶數,所以無法湊出奇數。

那麼我們很容易發現,其實只要給出的數都是某一個數的倍數,即它們的最大公約數為k>1,那麼不是k的倍數的數就無法被湊出來。

但是如果它們的最大公約數為1呢?我們無法確定。

當然,我們現在就可以將我們的發現加入到程式當中,來提高我們的得分。

那麼問題來了,怎麼才能求出最大公約數呢?

如果你聽過我講的上一屆的省賽題,你一定會記得最後一題的解法:模擬輾轉相減。

那麼我們用輾轉相減,就可以完成求最大公約數了。不過,其實我們有效率更高的方法:輾轉相除法。

實際上,輾轉相除就是將輾轉相減中的多個減法連在了一起。因為一個較大數減去一個較小數直到差小於較小數為止,這種操作就等同於模運算(取餘)。

它的時間複雜度是多少呢?考慮到被除數=除數*商+餘數,餘數小於除數,也就是說最劣情況下餘數也不會超過被除數的一半。所以輾轉相除法的時間複雜度是O(logn)的,其中n為被除數,也就是求最大公約數裡兩個數中較大的那個。

程式碼及執行結果

#include <cstdio>
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 5e5 + 5;
int n, a[MAX_N];
bool able[MAX_M];
// 以下這段程式碼被稱為輾轉相除法(歐幾里得演算法)
int gcd(int x, int y) {
    if (y == 0) return x;
    else return gcd(y, x % y);
}
bool judge(int x) {
    if (x <= 0) return false;
    if (able[x]) return true;
    for (int i = 0; i < n; i++) {
        if (x == a[i]) return able[x] = true;
        if (able[x - a[i]]) return able[x] = true;
    }
    return false;
}
int main() {
    int ans = 0, GCD = 0;
    scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &a[i]);
        GCD = gcd(a[i], GCD);
    }
    if (GCD > 1) {
        printf("INF\n");
    }
    else {
        for (int i = 1; i <= 5e5; i++) {
            if (!judge(i)) ans++;
        }
        printf("%d\n", ans);
    }


    return 0;
}

實際上,到目前為止,我們已經不用再往下研究了,因為這個程式足以讓我們獲得滿分。當然,在比賽當中,如果你有充裕的時間,往下想也是理所應當的。

第八題的拓展

作為拓展內容,下面的內容會涉及到比較多的演算法及數學知識。

第三種做法的正確性

首先我們來探究在給定的數最大公約數為1的情況下無法湊出的數是否是有限的。

根據上面的講解:如果一個數可以被湊出來,那麼它肯定是給定的數或者一個可以湊出來的數加上一個給定的數。

同理,如果一個數不可以被湊出來,那麼它減去一個給定的數也是不能被湊出來的。

那麼我們就會發現,不可以被湊出來的數在最稀疏的情況下也應該隔x出現一次(x為一個給定的數)。

我們來考慮這個性質的本質:對於x來說,所有的數其實都可以分成x類:根據對x的餘數來分。

只要某類裡的一個數被湊出來,那麼這類數中無法被湊出來的數就一定是有限的。

這種劃分出來的類被稱為x的完全剩餘系。

那麼我們只需要考慮模x=0,1,2,3……x-1的數能不能湊出來就可以了。

很明顯,如果模x=1可以被湊出來,其它就一定能被湊出來,所以我們只需要考慮這一種情況。

那麼也就是說,我們考慮的是這些數湊出一個數減去若干個x能否等於1。

現在我們來學習一個新知識:裴蜀定理


以上摘自維基百科

如果你擅長離散數學,那麼你會發現裴蜀定理在整環上不證自明:在主理想環中,a和b的最大公約元被定義為理想aA + bA的生成元。

現在我們假設x以外的n-1個數為$y_1$,$y_2$……$y_{n-1}$。那麼對於這n個數,裴蜀定理能否成立呢?

由於最大公約數這種運算本身具有結合律,所以我們將任意兩個數合併,替換成它們的最大公約數,這樣進行n-2次操作,就會轉化為裴蜀定理的形式了。

也就是說,如果這n個數互素,那麼它們湊不出來的數一定是有限個。

下一個問題,如何確定我們判斷的範圍呢?

考慮我們湊數的過程,實際上就是湊x的完全剩餘系。

我們假定x的完全剩餘系中,每個類的數被湊出來之後就不再湊這一類。

那麼最大的無法湊出來的數加上x就是最後一個被湊出來的數。

所以我們考慮這裡最後一個被湊出來的數,假設它是由m個給定的數相加而成。

按照順序,我們記為$a_1$,$a_2$,$a_3$……$a_m$,並記$\sum_{i=1}^{k}a_i$為$S_k$

我們可以發現一個性質:如果$S_i$和$S_j$模x同餘,那麼$a_{i+1}+a_{i+2}+……a_j$就是不必要的,它們的和模x=0。

根據這個性質,我們可以得到一個結論:在最優情況下,m必定小於等於x。

這裡用到了組合數學裡的一個著名定理:鴿巢原理



以上摘自維基百科

我們可以發現,由於要滿足每一個$S_k$都不相同,它們的數量必然不會超過x的完全剩餘系的大小:x。

這樣我們就可以確定無法湊出的數的上限了:x*剩餘數裡的最大數。

帶回題中,我們考慮x不超過100,而剩餘的數最大也不超過100,我們只要判斷到10000即可。

動態規劃

分析

我們前面提到的記憶化搜尋,實際上是一種動態規劃的實現形式。而動態規劃,是對一類演算法的總稱。


以上摘自維基百科

如果一個問題可以用動態規劃解決,它需要具有無後效性:每一個問題的答案只由它的子問題答案構成,未來問題的答案不會對其產生影響。

例如當前這道題:每一個可以湊出來的數只由比它小的那些數決定,比它大的數無論能不能湊出來都不會對它本身產生影響。

動態規劃除去記憶化搜尋以外,還有一種解法:遞推,這種方法寫起來會更加簡便。

而遞推中需要有遞推式,正如記憶化搜尋中的遞迴式。對於這道題而言,我們將每一個給定的數都更新所有的數,那些可以被湊出來的數加上給定的這個數若干倍,其和都可以被湊出來。

程式碼及執行結果

#include <cstdio>
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 1e4 + 5;
int n, a[MAX_N];
bool able[MAX_M];
int gcd(int x, int y) {
    if (y == 0) return x;
    else return gcd(y, x % y);
}
int main() {
    int ans = 0, GCD = 0;
    able[0] = true;
    scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &a[i]);
        GCD = gcd(a[i], GCD);
        // 以下這段程式碼被稱為動態規劃的遞推式
        for (int j = 0; j + a[i] <= 1e4; j++) {
            able[j + a[i]] = (able[j + a[i]] || able[j]);
        }
    }
    if (GCD > 1) {
        printf("INF\n");
    }
    else {
        for (int i = 1; i <= 1e4; i++) {
            if (!able[i]) ans++;
        }
        printf("%d\n", ans);
    }
    return 0;
}


測試一下:

$\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$

$$ x = \dfrac{-b \pm\sqrt{b^2 - 4ac}}{2a} $$