(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} $$