【20200312程序设计思维与实践 CSP-M1&补题】

目录

    • A - 咕咕东的奇遇
      • 题意
      • 思路
      • 总结
      • 代码
    • B - 咕咕东想吃饭
      • 题意
      • 思路
      • 总结
      • 代码
    • C - 可怕的宇宙射线
      • 题意
      • 思路
      • 总结
      • 代码


A - 咕咕东的奇遇

题意

咕咕东是个贪玩的孩子,有一天,他从上古遗迹中得到了一个神奇的圆环。这个圆环由字母表组成首尾相接的环,环上有一个指针,最初指向字母a。咕咕东每次可以顺时针或者逆时针旋转一格。例如,a顺时针旋转到z,逆时针旋转到b。咕咕东手里有一个字符串,但是他太笨了,所以他来请求你的帮助,问最少需要转多少次。


思路

显然可以在O(n)复杂度内解决问题。

设计了函数Ring(char x,char y)代表转环操作,返回从x转到y最短距离:顺时针与逆时针转距离中的较小值。累加字符串中所有相邻字符的转动距离,即可得到答案。


总结

本题较易,简单地抽象出循环累加关系即可解决。
牢记教训:csp赛制下提交代码小心误操作!!!


代码

#include 
using namespace std;

int Ring(char x,char y){//转环操作 
	int m=y;
	int n=x;
	int p;
	
	if(m>n){
		p=m;
		m=n;
		n=p;
	}
	
	int a=n-m;
	int b=26+m-n;
	
	int ans=(a<b)?a:b;//返回顺时针和逆时针转数中的较小值 
	return ans;
}

int main(){
	string s;
	cin>>s;
	int len=s.length();
	int cot=0;
	cot+=Ring('a',s[0]);//第一转 
	for(int i=0;i<len-1;i++){//余下len-1转 
		cot+=Ring(s[i],s[i+1]);
	}
	cout<<cot<<endl;
}

B - 咕咕东想吃饭

题意

咕咕东考试周开始了,考试周一共有n天。他不想考试周这么累,于是打算每天都吃顿好的。他决定每天都吃生煎,咕咕东每天需要买ai个生煎。但是生煎店为了刺激消费,只有两种购买方式:①在某一天一次性买两个生煎。②今天买一个生煎,同时为明天买一个生煎,店家会给一个券,第二天用券来拿。没有其余的购买方式,这两种购买方式可以用无数次,但是咕咕东是个节俭的好孩子,他训练结束就走了,不允许训练结束时手里有券。咕咕东非常有钱,你不需要担心咕咕东没钱,但是咕咕东太笨了,他想问你他能否在考试周每天都能恰好买ai个生煎。


思路

本题同样可以通过递推简单解决。

在最后一天之前,每天的购买策略都由当天要消费生煎数的奇偶决定。减去昨天预购的生煎数(0个或1个)后,剩下的生煎为奇数则为明天买券,为偶数则不买。直到最后一天,只能选择「当天两个生煎」的策略,此时检验昨天的券数能否使今天恰好吃目标数目,得到结果。


总结

本题难度不大,但依然有一些特判要考虑的细节。其中,原始思路中没有考虑到计划中可能有一天吃0个生煎的情况,只做了奇偶判断,如果没有样例数据的提示就会得到错误的答案。日后考虑只该更加全面。


代码

#include
using namespace std;

int a[100050];

int main(){
	int n;
	cin>>n;
	bool fail=0;//购买不可实现 
	bool yesterday=0;//记录昨天有无优惠券 
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<n;i++){//递推前n-1天 
		if(a[i]==0){//一天不吃生煎,任何策略不可能满足 
			fail=1;
			break;
		}
		if((a[i]-yesterday)%2)yesterday=1;//减去优惠券后的差,奇数买券,偶数不买 
		else yesterday=0;
	}
	
	//检验最后一天 
	if(a[n]==0)fail=1;//特判最后一天不吃 
	else if(!fail)fail=(a[n]-yesterday)%2;//检查能否恰好吃完 
	
	if(fail)cout<<"NO"<<endl;
	else cout<<"YES"<<endl;
}

C - 可怕的宇宙射线

题意

众所周知,瑞神已经达到了CS本科生的天花板,但殊不知天外有天,人外有苟。在浩瀚的宇宙中,存在着一种叫做苟狗的生物,这种生物天生就能达到人类研究生的知识水平,并且天生擅长CSP,甚至有全国第一的水平!但最可怕的是,它可以发出宇宙射线!宇宙射线可以摧毁人的智商,进行降智打击!
宇宙射线会在无限的二维平面上传播(可以看做一个二维网格图),初始方向默认向上。宇宙射线会在发射出一段距离后分裂,向该方向的左右45°方向分裂出两条宇宙射线,同时威力不变!宇宙射线会分裂n次,每次分裂后会在分裂方向前进ai个单位长度。
现在瑞神要带着他的小弟们挑战苟狗,但是瑞神不想让自己的智商降到普通本科生那么菜的水平,所以瑞神来请求你帮他计算出共有多少个位置会被"降智打击"


思路

本题若采用暴力方法计算出所有射线路径,复杂度高达指数级,显然要采取手段对分裂过程进行剪枝。

最终采取的方案是筛除重复的层次遍历。

层次遍历利用循环与队列实现,队列为全局实例,去重在向队列中添加元素的过程中完成。

为此,设计了结构体SpaceSpace拥有数据成员reach标记是否已访问;拥有数据成员direction[10][7],该二维数组第一维为方向(取值范围0-7),第二维是射线传播距离(取值范围1-5)。开辟类型为Space的二维数组S[310][310]表示整个坐标平面(横纵坐标范围超过射线可能到达的最远距离30×5×2=300)。

为了便于参数传递,设计了结构体unit,表示一次分裂后产生的新射线段起点,包含横坐标、纵坐标、发射方向、传播距离4个信息;将每一段传播过程封装成函数shotshot接受一个“unit”传入的信息,更新沿途坐标的访问情况,到达分裂点后根据判断标准(见下文详细描述)决定是否将产生的新“unit”加入队列。

每一层的扫描过程:循环从队列中取出射线段起点信息单元,传入shot进行一次传播,随后分裂产生出两个新的射线段起点信息,此时做判断:检查数组S,若当前坐标在 该方向传播距离上已被标记,则舍去这个起点信息,否则将该起点信息加入队列,同时标记S相应数组成员的数据成员。准备计数用的辅助变量判断层与层之间的分隔,在每一层扫描完成后重置数组Sdirection成员。

通过这样的记忆化判断,可以将每一层分裂中产生的“完全一致”的射线起点去除,将指数级的分裂过程大幅度削减,最终使时间复杂度下降到接受范围内。

最后扫描整个二维平面的reach标记,得到答案。


总结

最初递交时,模糊感觉到剪枝极为复杂(事实上的确如此,最终剪枝需要对四维数组进行分裂数次重置),加上对暴力求解的计算量缺乏认知,递交了指数级的递推算法。实际上比赛截止前已经发现最大规模的数据在自己电脑上计算需要半分钟,显然超时。

意识到优化的必要性,补题时重新考虑思路。结合上课所学意识到应该向剪枝搜索方向思考,重新整理剪枝的想法。通过对暴力计算过程的冗余的思考,发现暴力程序对于完全一致的分裂进行了重复计算,而分裂次数足够多时这种完全一致出现的机会并不小,于是在原暴力程序的基础上增加标记:除了reach标记记录访问情况,还应该有标记来记录分裂点的情况,使跳过完全一致的分裂点成为可能。故给坐标结构体增加了新的数据成员:三维数组direction[分裂次数][传播方向][传播距离]。加上原本坐标就是由结构体的二维数组表示的,实际上是构造了一个五维数组。在分裂的递推过程中,一旦下一次分裂坐标点的三维所代表的数组成员已被标记,就意味着该次分裂已被进行过,不需再重复。这实际上就是一个记忆化的深度优先搜索。新程序有效削减了时间复杂度,顺利通过vj。但是这个做法因为高维数组的存在,空间消耗高达200M,超过了题目更新后128M的内存限制。

进一步优化程序。想到将高维数组的分裂次数这个时间维度去除,利用层次搜索代替深搜来等效替代。建立起上文所述的传参单元、射线封装、队列、循环,却发现运算结果开始出错。仔细排查后发现,在层次遍历每一层扫完后应该将标记所用的数组的direction成员全部重置,因为在不同层中,即使四维完全一样,后续的分裂序列也会不同,不重置会把不该跳过的分裂信息跳过。修改完成后再次提交,时间几乎没有增加,而内存占用减少到了仅7M。

经过两次代码重构,将时间效率不可接受的暴力过程修改为了时间空间都合意的较优程序,成功做到了算法优化。


代码

#include
#include
using namespace std;

struct unit{//射线段起点信息单元 
	int idx;//分裂次数 
	int dir;//方向 
	int x;//横坐标 
	int y;//纵坐标 
	
	unit(int a,int b,int c,int d){
		idx=a;
		dir=b;
		x=c;
		y=d;
	}
	
	void operator=(unit& u){
		idx=u.idx;
		dir=u.dir;
		x=u.x;
		y=u.y;
	}
};

queue<unit> Q;//用于层搜的队列 
int thisF,nextF;//用于分层的辅助计数变量

struct Space{//位置单元 
	bool reach;//访问标记 
	bool direction[10][7];//分裂发生标记,第一维为方向,第二维为传播距离 


int n;
int a[35];//传播距离数组 
Space S[310][310];//访问与 

void clear(){//重置S的direction成员 
	for(int i=1;i<=305;i++){
		for(int j=1;j<=305;j++){
			for(int d=0;d<=7;d++){
				for(int p=1;p<=5;p++){
					S[i][j].direction[d][p]=false;
				}
			}
		}
	}
}

//方向数组 
int dx[]={0,1,1,1,0,-1,-1,-1};
int dy[]={-1,-1,0,1,1,1,0,-1};

void shot(int idx,int dir,int x,int y){//一次射线传播 参数:传播次数,传播方向,起点横坐标,起点纵坐标 
	if(idx>n)return;//终止 
	
	int power=a[idx];
	int tempx,tempy;
	tempx=x;
	tempy=y;
	for(int i=1;i<=power;i++){//将沿途位置设为已访问 
		tempx+=dx[dir];
		tempy+=dy[dir];
		S[tempx][tempy].reach=true;
	}
	
	if(!S[tempx][tempy].direction[(dir+1)%8][a[idx+1]]){//若一侧分裂未被标记则进入if肢 
		S[tempx][tempy].direction[(dir+1)%8][a[idx+1]]=true;//标记 
		unit temp(idx+1,(dir+1)%8,tempx,tempy);
		Q.push(temp);//入队 
		nextF++;
	} 
	
	if(!S[tempx][tempy].direction[(dir+7)%8][a[idx+1]]){//若另一侧分裂未被标记则进入if肢 
		S[tempx][tempy].direction[(dir-1)%8][a[idx+1]]=true;//标记 
		unit temp(idx+1,(dir+7)%8,tempx,tempy);
		Q.push(temp);//入队 
		nextF++;
	} 
	
}

void solve(){//层次遍历求解 
	
	//将起点添入队列 
	unit temp(1,0,151,151);
	Q.push(temp);
	thisF++;
	
	while(!Q.empty()){//循环直到队空 
		
		//取出一个射线起点,发生一次射线传播与分裂(同时生成新起点并添入队列) 
		temp=Q.front();
		Q.pop();
		thisF--;
		shot(temp.idx,temp.dir,temp.x,temp.y);
		
		if(thisF==0){//层间分界,重置标记 
			clear();
			thisF=nextF;
			nextF=0;
		}
	}
}

int count(){//扫描总位置数 
	int cot=0;
	for(int i=1;i<=305;i++){
		for(int j=1;j<=305;j++){
			if(S[i][j].reach){
				cot++;
				
			}
		}
	}
	return cot;                               
}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)cin>>a[i];
	
	solve();
	int ans=count();
	cout<<ans<<endl;
}

你可能感兴趣的:(【20200312程序设计思维与实践 CSP-M1&补题】)