解题报告

Posted win10crz

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了解题报告相关的知识,希望对你有一定的参考价值。

解题报告

学生:陈睿泽

【引言】

这是一道我们机房的练习题目,传说 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\%) 的数据:所有的 (Nleq 20)

对于所有的数据:所有的 (Nleq 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
");   //输出。
		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,&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
");
	}
	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} ot=0)(t) 为任意不大于 (n) 的数),那么久说明有解,否则反之。

但是,这题我们是无法打成代码的,或者准确来说,我们无法打出能不计时间跑出 (100 pts) 的代码的,仔细想想是为什么呢?

因为这题的背包的大小(就是 (m))太大了!题目给的数据范围里 (m leq 2^{31}-1),即使我们使用了滚动数组, (f) 的大小也要开成 (f[2147483647]),这样的大小肯定是不允许的。并且这题用 (01) 背包的优化也是一个“假优化”,因为该种优化方法的时间复杂度为 (Theta(NM)),只能在 (n) 特别大,而 (m) 又十分小的情况下才能比朴素 dfs 更优,否则,(m) 极其大的数据也会将这种做法卡掉。

【简单展示一下部分分代码】

#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],f[1000005];   //含义同上。 
bool flag;
int main() {
	freopen("birthday.in","r",stdin);
	freopen("birthday.out","w",stdout);
	while(scanf("%d%d
",&n,&m)!=EOF) {
		flag=0;
		memset(f,0,sizeof(f));   //初始化。 
		for(rg int i=1;i<=n;++i)
			price[i]=read();
		f[0]=1;    //注意!这里一定要赋初值,因为肯定能花费 0 元的(即什么都不买)。 
		for(rg int i=1;i<=n;++i){   //dp 的具体过程。 
			for(rg int j=1;j<=m;++j){
				f[j]+=f[j-price[i]];   //其实就是上面的状态转移方程,只是将二维压成了一维,简单思考不难理解。 
			}
		}
		if(f[m]) printf("YES
");   //输出。 
		else printf("NO
");
	}
	return 0;
}

那么,我们该怎么办呢?

我们来回忆一下 dfs 的算法,dfs 算法会产生一颗极其庞大的搜索树,一旦我们走错了路径,就会浪费许多时间,因而,我们要引出两种优化 dfs 的方法:双向 dfs 和 迭代加深。双向 dfs 是我们从初始状态和目标状态同时开始搜索,可以产生两颗深度减半的搜索树,增强搜索的效率;而迭代加深则是通过限制搜索的深度来防止搜索树“误入歧途”。 既然这题我们可以知道目标的搜索状态,那么我们就可以试试进行双向搜索。

【优化方案 (2):使用双向 dfs】

其实这里的双向 dfs 可以简单理解成是一种特殊的预处理,在第一个搜索中(即从初始状态开始的搜索),我们只搜索到前 (frac{n}{2}) 个物品,将这些的每个状态的价值总和在一个 HASH 中标记;而我们在第二个搜索中(即从目标状态开始的方向搜索)我们将所期望的价值不断减去后 (frac{n}{2}) 物品中搜索到的价值,一旦剩下的价值在 HASH 中标记过,那么我们就可以判断有解了;如果一直搜完都没有出现标记,那么便是无解。

这种做法是将原本的搜索树拆分成两个深度折半的搜索树,所以时间复杂度几位两个减半的组合数公式,就是:(2 imes 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
"); 
		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
");
		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
");
}
int main() {
	freopen("birthday.in","r",stdin);
	freopen("birthday.out","w",stdout);
	while(scanf("%d%d
",&n,&m)!=EOF) {
		b.reset();   //初始化 HASH。
		flag=0;
		for(rg int i=1;i<=n;++i)
			price[i]=read();
		dfs();   //开始搜索。
	}
	return 0;
}

【总结】

本题看上去是思路简单明了,但是它的数据范围却让我们有了更多的思考。在我们遇到需要深度思考的一道题时,我们应该从简到繁,逐步探索优化的方案,从题目的描述中寻找蛛丝马迹,这样我们才能在赛场上用更少的时间打出更优的代码。因为笔者水平有限,如有不严谨的地方,请见谅。

以上是关于解题报告的主要内容,如果未能解决你的问题,请参考以下文章

数据结构荣誉课---第一次实验解题报告

数据结构荣誉课---第一次实验解题报告

数据结构荣誉课---第一次实验解题报告

NOIP2015 解题报告

数据结构荣誉课--第三次实验解题报告

数据结构荣誉课--第三次实验解题报告