1. 程式人生 > 實用技巧 >解題報告

解題報告

解題報告

學生:陳睿澤

【引言】

這是一道我們機房的練習題目,傳說 wjz 和 hsy 用了10min就秒切了,而我這個無力的小蒟蒻調了 1h 才打了出來。


【題目描述】

多多今天很高興,因為他的好朋友蘋果要過生日了。由於今天多多得到了兩張價值不菲的 SHOP 購物券,所以他決定買 \(N\) 件禮物送給蘋果。

一個下午過去了,多多選好了 \(N\) 個禮物,而且它們的價格之和恰好為兩張購物券的面額之和。當多多被自己的聰明所折服,高興的去結賬時,他突然發現 SHOP 對購物券的使用有非常嚴格的規定:一次只允許使用一張,不找零,不與現金混用。多多身上沒有現金,並且他不願意放棄挑選好的禮物。這就意味著,他只能通過這兩張購物券結賬,而且每一張購物券所購買的物品的總價格,必須精確地等於這張購物券的面額。

怎麼樣才能順利的買回這 \(N\) 件禮物送給蘋果呢?本題的任務就是幫助多多確定是否存在一個購買方案。已知其中一張購物券的面額以及所有商品的價格,只需要確定是否能找到一種方案使得選出來的物品價格總和正好是這張購物券的面額即可。

【輸入格式】

有多組測試資料。每組資料的第一行為兩個整數 \(N\)\(M\),分別表示多多一共挑選了 \(N\) 個物品送給蘋果以及多多的一張購物券的面額為 \(M\)。接下來一行有 \(N\) 個用空格隔開的正整數,第 \(i\) 個數表示第 \(i\) 個物品的價格。

【輸出格式】

包含若干行,每行一個單詞 YES 或者 NO,分別表示存在或不存在一個購買方案。

【輸入樣例】

10 2000
1000 100 200 300 400 500 700 600 900 800
10 2290
1000 100 200 300 400 500 700 600 900 800

【輸出樣例】

YES
NO

【資料規模】

對於 \(30\%\) 的資料:所有的 \(N\leq 20\)

對於所有的資料:所有的 \(N\leq 40\),並且 \(M\) 和物品的總價值不超過 \(2^{31}-1\),測試組數不超過 \(10\) 組,不少於 \(5\) 組。

【題目分析】

這題首先最顯然的做法就是普通的 dfs,通過搜尋來窮舉所有物品的排列組合方案;一旦和購物券的價值符合,就可以判斷它是有解的;而直到所有的組合都窮舉完時都沒有和購物券價值符合,就是無解。

在具體的過程中,我們通過記錄當前搜尋到的物品的編號,不斷向後搜尋,直到與購物券的價值符合。在程式碼中需要用一些小技巧讓 dfs 程式回溯。

【使用的變數】

buf*p1*p2:快讀所使用的變數,與題目無關。

nm:對應題目中的 \(N\)\(M\)

price[]:表示每個物品的價格。

flag:標記是否有解。

dfs 函式內的 idx sumidx 表示當前搜尋到的物品的編號,sum 表示當前物品的總價值。

【程式碼】
#include<bits/stdc++.h>
using namespace std;
#define rg register
//快讀部分
char buf[1<<21],*p1=buf,*p2=buf;
inline int getc() {
    return p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++;
}
inline int read() {
	rg int ret=0,f=0;
        char ch=getc();
        while(!isdigit(ch)) {
        	if(ch=='-') f=1;
		ch=getc();
	}
        while(isdigit(ch)) {
		ret=ret*10+ch-48;
		ch=getc();
	}
        return f?-ret:ret;
}
int n,m,price[55];  //n,m 如題,price 陣列代表著價格。
bool flag;   //表示當前情況是否有解,便於輸出和退出 dfs。
inline void dfs(int idx,int sum) {  //dfs 程式,idx 表示當前搜尋到的編號,sum 表示當前價格總和。
	if(sum>m||flag)   //當目前累計的價格超過了 m,那麼就不可能滿足了,繼續回溯;flag 的值為 true 時,代表已經有解,不斷回溯達到結束 dfs 的效果。
		return;      //這裡沒使用 exit(0) 的原因是本題有多組資料,不能直接結束程式。
	if(sum==m) {     //當符合題意時。
		printf("YES\n");   //輸出。
		flag=true;      //標記有解。
		return;      //進行回溯。
	}
	for(rg int i=idx;i<=n;++i)   //如果既沒有達到回溯條件,也沒有符合題目要求,那麼就繼續搜尋。
		dfs(i+1,sum+price[i]);
}
int main(){
	freopen("birthday.in","r",stdin);
	freopen("birthday.out","w",stdout);    
	while(scanf("%d %d\n",&n,&m)!=EOF) {   //多組資料的輸入,利用 scanf 的返回值總是正數的特性,EOF 可以簡單理解為`-1`。
		flag=0;   //注意 flag 的初始化。
		for(rg int i=1;i<=n;++i)
			price[i]=read();
		dfs(1,0);  //進行搜尋。
		if(flag==false)   //如果無解,輸出 `NO`。
			printf("NO\n");
	}
	return 0;
}

這種做法的最差複雜度就是它窮舉的方案數,通過組合數公式我們可以計算得出方案的總數為 \(\sum_{i=1}^NC(N,i)\),即為 \(\sum_{i=1}^N \frac{N!}{i!(N-i)!}\) 。所以它的時間複雜度即為 \(\Theta(\sum_{i=1}^N \frac{N!}{i!(N-i)!})\),這樣的話肯定會嚴重超時,那麼我們就要想一想有沒有什麼優化的方案。

【優化方案】

【優化方案 \(1\):使用 \(01\) 揹包進行優化】

我們可以將這題抽象為一個 \(01\) 揹包,其中優惠券的價格為揹包的容量,每個商品的價格就是每個物品的重量。因為這題我們求的不是最優的花費,我們就可以將每個物品的價格假定為 \(1\)。當物品的總和恰好為揹包的容量時,我們就可以判斷優解。

但是這樣並不是最好的解決方法,我們可以換一種思想,普通 \(01\) 揹包中的 \(f_{i,j}\) 表示當使用 \(j\) 空間存放前 \(i\) 個物品時能獲得的最大價值,而我們這裡可以轉換為 \(f_{i,j}\) 表示恰好用前 \(i\) 個物品花完 \(j\) 元的方案數,初始的時候為 \(0\) 。通過簡單的思考我們不難得出:如果可以恰好用前 \(i\) 個物品花完 \(j\) 元的話,並且存在一個價值為 \(k\) 的物品,那麼也可以恰好花完 \(j+k\) 元,並且恰好花完 \(j+k\) 的方案數為自己本身的方案數加上恰好花完 \(j\) 元的方案數,然後我們就可以得到 dp 的狀態轉移方程:\(f_{i,j}=f_{i-1,j}+f_{i-1,j-price[i]}\),當 \(f\) 陣列的第二維是 \(m\) 時,第一維為任意數,該元素的值不為 \(0\)(即 \(f_{t,m}\not=0\)\(t\) 為任意不大於 \(n\) 的數),那麼久說明有解,否則反之。

但是,這題我們是無法打成程式碼的,或者準確來說,我們無法打出能不計時間跑出 \(100 pts\) 的程式碼的,仔細想想是為什麼呢?

因為這題的揹包的大小(就是 \(m\))太大了!題目給的資料範圍裡 \(m \leq 2^{31}-1\),即使我們使用了滾動陣列, \(f\) 的大小也要開成 \(f[2147483647]\),這樣的大小肯定是不允許的。並且這題用 \(01\) 揹包的優化也是一個“假優化”,因為該種優化方法的時間複雜度為 \(\Theta(NM)\),只能在 \(n\) 特別大,而 \(m\) 又十分小的情況下才能比樸素 dfs 更優,否則,\(m\) 極其大的資料也會將這種做法卡掉。

那麼,我們該怎麼辦呢?

我們來回憶一下 dfs 的演算法,dfs 演算法會產生一顆極其龐大的搜尋樹,一旦我們走錯了路徑,就會浪費許多時間,因而,我們要引出兩種優化 dfs 的方法:雙向 dfs 和 迭代加深。雙向 dfs 是我們從初始狀態和目標狀態同時開始搜尋,可以產生兩顆深度減半的搜尋樹,增強搜尋的效率;而迭代加深則是通過限制搜尋的深度來防止搜尋樹“誤入歧途”。 既然這題我們可以知道目標的搜尋狀態,那麼我們就可以試試進行雙向搜尋。

【優化方案 \(2\):使用雙向 dfs】

其實這裡的雙向 dfs 可以簡單理解成是一種特殊的預處理,在第一個搜尋中(即從初始狀態開始的搜尋),我們只搜尋到前 \(\frac{n}{2}\) 個物品,將這些的每個狀態的價值總和在一個 HASH 中標記;而我們在第二個搜尋中(即從目標狀態開始的方向搜尋)我們將所期望的價值不斷減去後 \(\frac{n}{2}\) 物品中搜索到的價值,一旦剩下的價值在 HASH 中標記過,那麼我們就可以判斷有解了;如果一直搜完都沒有出現標記,那麼便是無解。

這種做法是將原本的搜尋樹拆分成兩個深度折半的搜尋樹,所以時間複雜度幾位兩個減半的組合數公式,就是:\(2\times \sum_{i=1}^{\frac{N}{2}}C(\frac{N}{2},i)\),即為:\(\sum_{i=1}^\frac{N}{2} \frac{\frac{N}{2}!}{i!(\frac{N}{2}-i)!}\),所以該做法的時間複雜度為 \(\Theta(\sum_{i=1}^\frac{N}{2} \frac{\frac{N}{2}!}{i!(\frac{N}{2}-i)!})\),由於筆者水平有限,無法對該時間複雜度進行化簡計算,請見諒。

在該優化方法中,我們會使用一種查詢的時間複雜度為 \(\Theta(1)\) 的資料結構 HASH,這題我們的重點在於理解雙向 dfs,程式碼中 HASH 我們用 STL 中提供的 bitset 暫時代替一下,不過在比賽中,我們最好還是手打 HASH。

【AC 程式碼】

#include<bits/stdc++.h>
using namespace std;
#define rg register
bitset<0x7fffffff> b;   //充當 HASH  的作用。
//快讀部分
char buf[1<<21],*p1=buf,*p2=buf;
inline int getc() {
    return p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++;
}
inline int read() {
	rg int ret=0,f=0;
        char ch=getc();
        while(!isdigit(ch)) {
        	if(ch=='-') f=1;
		ch=getc();
	}
        while(isdigit(ch)) {
		ret=ret*10+ch-48;
		ch=getc();
	}
        return f?-ret:ret;
}
int n,m,price[55]; //變數含義同上。
bool flag;
inline void dfs1(int sum,int idx) { //dfs 程式,idx 表示當前搜尋到的編號,sum 表示當前價格總和。
	if(sum>m||flag)    //當目前累計的價格超過了 m,那麼就不可能滿足了,繼續回溯;flag 的值為 true 時,代表已經有解,不斷回溯達到結束 dfs 的效果。
		return;
	if(sum==m) {     //如果在第一個dfs中已經有解了,那麼我們就可以結束了。
		printf("YES\n"); 
		flag=1; return;   //標記,回溯。
	}
	b.set(sum,1);    //標記 HASH,表示能夠用前 n/2 個物品恰好用完 sum 元。
	for(rg int i=idx;i<=n/2;++i) {    //注意!我們這裡只搜尋到 n/2。
		dfs1(sum+price[i],i+1);     //繼續搜尋。
	}
}
inline void dfs2(int sum,int idx) {
	if(sum>m||flag)   //同上。
		return;
	if(b[m-sum]) {   //如果已經標記過可以,那麼就說明有解。
		printf("YES\n");
		flag=1; return;    //標記,回溯。
	}
	for(rg int i=idx;i<=n;++i) {
		dfs2(sum+price[i],i+1);  //繼續搜尋。
	}
}
inline void dfs() {
	dfs1(0,1);   //從編號 1 開始進行前半個搜尋。
	if(flag)    //如果已經有解了,就可以跳出了。
		return;
	dfs2(0,n/2+1);   //再從編號 n/2+1 進行第二個搜尋。
	if(flag==0)   //判斷無解。
		printf("NO\n");
}
int main() {
	freopen("birthday.in","r",stdin);
	freopen("birthday.out","w",stdout);
	while(scanf("%d%d\n",&n,&m)!=EOF) {
		b.reset();   //初始化 HASH。
		flag=0;
		for(rg int i=1;i<=n;++i)
			price[i]=read();
		dfs();   //開始搜尋。
	}
	return 0;
}

【總結】

本題看上去是思路簡單明瞭,但是它的資料範圍卻讓我們有了更多的思考。在我們遇到需要深度思考的一道題時,我們應該從簡到繁,逐步探索優化的方案,從題目的描述中尋找蛛絲馬跡,這樣我們才能在賽場上用更少的時間打出更優的程式碼。因為筆者水平有限,如有不嚴謹的地方,請見諒。