1. 程式人生 > 實用技巧 >LOJ3342 「NOI2020」製作菜品

LOJ3342 「NOI2020」製作菜品

題目連結

博主有幸參加了NOI2020,考場上的經歷和心得請見這篇文章。這裡就不嘮叨了。

本題題解

本題的突破口在於\(m\)\(n\)的關係。也就是資料範圍表裡這些奇怪的限制:\(m=n-1\)\(m\geq n-1\)\(m\geq n-2\)。我們一個一個來看。

走出第一步:\(m=n-1\)

顯然,\(n\)種原材料,除了在輸出答案時,其他時候它們的原始順序對我們解題沒有任何影響。所以可以先將它們排序。現在假設\(d_1\leq d_2\leq \dots \leq d_n\)

考慮一種貪心:先用最大的原材料和最小的原材料一起做成第一道菜。然後把它們剩餘的部分,當做一種新的材料,插入回\(d\)

序列中,轉化為一個\(m-1\)道菜的子問題。

這種貪心在\(m=n-1\)時是正確的,而且一定有解。以下是證明:

引理1.1\(d_1<k\)

證明1.1

反證法,假設\(d_1\geq k\),則\((\sum_{i=1}^{n}d_i)\geq d_1\times n\geq k\times n\)。又因為\((\sum_{i=1}^{n}d_i)=m\times k\),所以\(m\times k\geq n\times k\)\(m\geq n\)。與\(m=n-1\)矛盾。故可以證明:\(d_1<k\)

引理1.2\(d_1+d_n\geq k\)

證明1.2

反正法,假設\(d_1+d_n<k\),則\((\sum_{i=1}^{n}d_i)=(d_1+d_n)+(\sum_{i=2}^{n-1}d_i)<(n-1)\times k\)。這與\((\sum_{i=1}^{n}d_i)=m\times k=(n-1)\times k\)矛盾。故可以證明:\(d_1+d_n\geq k\)

結合引理1.1和引理1.2,我們在貪心時,每次操作,一定會把最小的原材料和最大的原材料都用上,一定能用它們拼成一道菜,並且能把多餘的\(d_1+d_n-k\)作為一種“新的”原材料放回序列中。那麼,每次操作後,\(n\)\(m\)各減小\(1\)

,仍然滿足\(m=n-1\)。我們如此歸納下去,直到\(m=1,n=2\)時,直接拼成一道菜即可。

注意,在證明時,我們認為可以允許存在一道菜\(d_i=0\)(否則,\(n\)每次就不一定減少\(1\),而有可能減少\(2\):也就是\(d_1+d_n=k\)的情況)。這樣假設不會影響該做法的正確性,但在輸出答案時要注意判斷。

樸素地實現這一貪心,每次操作後將序列重新排序。時間複雜度\(O(mn\log n)\)。可以用\(\texttt{std::set}\)優化到\(O(m\log n)\)不過沒有必要。

繼續努力:\(m\geq n\)

\(m\geq n\)時,考慮向\(m=n-1\)轉化。

引理2.1\(d_n\geq k\)

證明2.1

反證法,假設\(d_n<k\),則\((\sum_{i=1}^{n}d_i)<n\times k\)。又因為\((\sum_{i=1}^{n}d_i)=m\times k\),所以\(m\times k<n\times k\)\(m<n\)。與\(m\geq n\)矛盾。故可以證明:\(d_n\geq k\)

於是我們每次使用\(d_n\)做一道菜,這樣\(m\)會減少\(1\)\(n\)不變。若干次後,一定能轉化為\(m=n-1\)的情況。然後按上一段所述的方法貪心構造即可。

走向正解:\(m=n-2\)

考慮把手上的原材料,分成兩部分,分別滿足\(m=n-1\)。具體來說,我們要證明:

引理3.1\(m=n-2\)時,有解當且僅當,存在一個集合\(S\subsetneq \{1,\dots ,n\}\),使得\((\sum_{i\in S}d_i)=(|S|-1)\times k\)

證明3.1

充分性:可以對\(S\)\(T=\{1,\dots ,n\}\setminus S\)這兩個集合分別構造方案。根據定義,顯然這兩個集合都滿足:“\(m=n-1\)”。所以一定是有解的。

必要性:考慮建一張\(n\)個點的圖。如果在最終方案下,第\(i\)種原材料和第\(j\)種原材料,曾經共同拼成過一道菜,則在點\(i\)和點\(j\)之間連一條邊。因為總共只有\(m\)道菜,所以最多會連出\(m=n-2\)條邊。因此這張圖一定不連通。此時必然至少有一個連通塊滿足“\(m=n-1\)”(也就是至少有一個連通塊是。不可能每個連通塊都有環,否則邊數不夠用了),它就是集合\(S\)。也就是說,有解時,必然存在一個這樣的集合\(S\)

如何劃分出這樣的集合\(S\)呢?\(2^n\)列舉肯定不行。考慮DP。

可以設計出這樣一個樸素的狀態:\(dp[i][j][w]\),表示考慮了前\(i\)種原材料,選出了\(j\)種原材料,它們的質量之和為\(w\),是否存在一種這樣的方案。轉移就考慮下一種原材料選或不選,分別轉移到\(dp[i+1][j+1][w+d_{i+1}]\)\(dp[i+1][j][w]\)。最終,如果存在一個\(j\)使得\(dp[n][j][(j-1)\times k]=1\),則有解,我們順著轉移的過程,反推回去就能得到集合\(S\)

時間複雜度\(O(n^2\sum d_i)=O(n^3k)\)

繼續優化,發現我們並不關心\(j\)是多少,只關心\(j\)\(w\)的關係:也就是\(w-j\times k=-k\)。於是我們可以令每樣物品的權值為\(v_i=d_i-k\)。設計一個新的狀態:\(dp[i][j]\),表示考慮了前\(i\)種物品,權值和為\(j\),是否存在這樣的方案。考慮下一種原材料選或不選,可以轉移到\(dp[i+1][j+v_{i+1}]\)\(dp[i+1][j]\)。最終只要看\(dp[n][-k]\)是否為\(1\)即可。另外,這種DP狀態下,第二維可能是負數(最小為\(-nk\))。所以在實現時,我們把數值統一加上\(nk\)即可。

在新的狀態下,DP的狀態數減小至\(n\times(nk+mk+1)\)個(第一維大小為\(n\),第二維在\([-nk,mk]\))。轉移是\(O(1)\)的。時間複雜度\(O(n^2k)\),還是不足以通過全部資料。

因為DP數組裡只存\(01\)兩種值,所以考慮用\(\texttt{bitset}\)優化DP。具體來說,把DP的第二維,看做一個大小為\(nk+mk+1\)\(\texttt{bitset}\),則DP的轉移就相當於將上一階段的\(\texttt{bitset}\)上它左移\(v_i\)位。即:\(dp[i]=dp[i-1]\operatorname{or}(dp[i-1]\ll v_i)\)。特別地,如果\(v_i\)為負數,要寫成右移\(|v_i|\)位。

時間複雜度\(O(\frac{n^2k}{w})\),其中\(w=64\)。可以通過本題。

還有一個小問題。做完這個DP後,只是知道了是否有解。但我們還需要構造出\(S\)集合。不過這也不難,通過現有的DP陣列就能構造出來。考慮定義一個\(\texttt{getS(i,x)}\)函式,用遞迴實現。保證傳入的\(i,x\)滿足\(dp[i][x]=1\)。顯然,當\(i>0\)時,\(dp[i-1][x]\)\(dp[i-1][x-v_i]\)必有至少一個為\(1\)。任選一個為\(1\)的遞迴下去即可。如果遞迴了\((i-1,x-v_i)\),相當於把原材料\(i\)加入集合\(S\),否則相當於加入另一個集合。初始時,傳入\(i=n,x=-k\)。遞迴的邊界是\(i=0\)時直接返回。這樣顯然可以構造出滿足我們要求的兩個集合(分別滿足\(m=n-1\))。

總時間複雜度\(O(\frac{n^2k}{w}+mn\log n)\)

參考程式碼:

//problem:LOJ3342
#include <bits/stdc++.h>
using namespace std;

#define pb push_back
#define mk make_pair
#define lob lower_bound
#define upb upper_bound
#define fi first
#define se second
#define SZ(x) ((int)(x).size())

typedef unsigned int uint;
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int,int> pii;

template<typename T>inline void ckmax(T& x,T y){x=(y>x?y:x);}
template<typename T>inline void ckmin(T& x,T y){x=(y<x?y:x);}

const int MAXN=500,MAXM=5000,MAXK=5000;
int n,m,K;
pii a[MAXN+5];
pair<pii,pii> ans[MAXM+5];
void work(pii a[],int n,int m,pair<pii,pii> ans[]){
	sort(a+1,a+n+1);
	for(int i=1;i<=m;++i){
		assert(n>0);
		int curm=m-i+1;
		if(n<=curm){
			assert(a[n].fi>=K);
			ans[i]=mk(mk(a[n].se,K),mk(0,0));
			a[n].fi-=K;
			sort(a+1,a+n+1);
		}
		else{
			assert(n==curm+1);
			assert(a[1].fi<K);
			assert(a[1].fi+a[n].fi>=K);
			ans[i]=mk(mk(a[1].se,a[1].fi),mk(a[n].se,K-a[1].fi));
			a[1].fi=a[1].fi+a[n].fi-K;
			a[1].se=a[n].se;
			--n;
			sort(a+1,a+n+1);
		}
	}
	for(int i=1;i<=m;++i){
		if(ans[i].se.se>ans[i].fi.se) swap(ans[i].fi,ans[i].se);
		if(ans[i].se.se)
			cout<<ans[i].fi.fi<<" "<<ans[i].fi.se<<" "<<ans[i].se.fi<<" "<<ans[i].se.se<<endl;
		else
			cout<<ans[i].fi.fi<<" "<<ans[i].fi.se<<endl;
	}
}
pii a1[MAXN+5],a2[MAXN+5];
pair<pii,pii> ans1[MAXN+5],ans2[MAXN+5];
bitset<MAXN*MAXK+(MAXN-2)*MAXK+5> dp[MAXN+5];
int bas,n1,n2;
void getset(int i,int x){
	if(i==0) return;
	if(dp[i-1][x+bas]){
		getset(i-1,x);
		a2[++n2]=a[i];
	}
	else{
		int v=a[i].fi-K;
		assert(dp[i-1][x-v+bas]==1);
		getset(i-1,x-v);
		a1[++n1]=a[i];
	}
}
void solve_case(){
	cin>>n>>m>>K;
	for(int i=1;i<=n;++i){
		cin>>a[i].fi;
		a[i].se=i;
	}
	if(n<=m+1){
		work(a,n,m,ans);
		return;
	}
	assert(n==m+2);
	bas=n*K;// DP陣列為了保證下標不為負而產生的的偏移量
	//dp[-x] -> dp[-x+bas]
	dp[0].reset();
	dp[0][0+bas]=1;
	bool flag=0;
	for(int i=1;i<=n;++i){
		int v=a[i].fi-K;
		if(v>0)
			dp[i]=(dp[i-1]|(dp[i-1]<<v));
		else if(v<0)
			dp[i]=(dp[i-1]|(dp[i-1]>>(-v)));
		else
			dp[i].reset();
		if(dp[i][-K+bas]==1){
			n1=n2=0;
			getset(i,-K);
			for(int j=i+1;j<=n;++j){
				a2[++n2]=a[j];
			}
			flag=1;
			break;
		}
	}
	if(!flag){
		cout<<-1<<endl;
		return;
	}
//	for(int i=1;i<=n1;++i) cerr<<a1[i].fi<<" "; cerr<<endl;
//	for(int i=1;i<=n2;++i) cerr<<a2[i].fi<<" "; cerr<<endl;
	int sum1=0,sum2=0;
	for(int i=1;i<=n1;++i) sum1+=a1[i].fi; assert(sum1==(n1-1)*K);
	for(int i=1;i<=n2;++i) sum2+=a2[i].fi; assert(sum2==(n2-1)*K);
	assert(n1>=1);
	assert(n2>=1);
	work(a1,n1,n1-1,ans1);
	work(a2,n2,n2-1,ans2);
}
int main() {
//	freopen("dish.in","r",stdin);
//	freopen("dish.out","w",stdout);
	int T;cin>>T;while(T--){
		solve_case();
	}
	return 0;
}