两个NOI题目的启迪8皇后和算24
论出于什么原因和目的,学习C++已经有一个星期左右,从开始就在做NOI的题目,到现在也没有正式的看《Primer C++》,不过还是受益良多,毕竟C++是一种”低级的高级语言“,而且NOI上的题目可以说是循序渐进。不仅仅是从ASM、VB.NET的角度看编程语言,这让我对编程语言语言的理解有了一些深入。
这一篇来记录一下”2.5基本算法之搜索“的两个问题解决的过程。它们有一些类似之处,也有很大不同。
一、8皇后
这是一个非常经典的问题:输出在8*8的棋盘上,摆放8个相互无法吃掉的皇后的全部摆法。一般认为有92种摆法,其中有一些通过镜像、旋转相互重合。因为皇后可以横向、纵向、斜向4个方向吃子且远近不计,所以放上一个皇后之后就会导致一些格子没法再放皇后了:典型的,我们在(0,0)放上一个皇后之后,下次再放就要避开满足x=0,y=0,x=y的格子,这也是简化算法的基础。这导致这个问题求解时,可以按行或按列依次搜索,因为前一行(或列)放置皇后之后就不能再放皇后了。这使得每次遍历都被限制在最多8格之内,从而形成了一颗”枝繁叶疏“的树,可以用回溯法等深度优先算法进行求解。
理论是美好的,实现的过程是残酷的,我用了若干小时编写这份代码,但最终很多理论都没有使用,所以写出来的代码没有什么特别的亮点,甚至自觉很丑陋,所以这篇只有算24的代码。不过在求解过程中,有一些小技巧还是值得和大家共同讨论的:
1、stack进行有序遍历:将y=0的8个位置push,在循环中把y=1-7的push(如果存在可能解),在循环开始输出y=7时的路径,这些路径就是解。
2、使用位棋盘,使用位棋盘可以大为加快记录皇后4向攻击位置(即不能再放皇后的位置)的计算速度:只需要读取mask[i]然后与当前的位棋盘进行按位与运算就得到了放置这个皇后之后的情况。用位棋盘速度非常块也非常节省空间,但是我在进行ULL类型|运算的时候,发现高32位没有被正确操作,可能是我的代码问题,也可能是编译器问题。可以用两个32位来代替,速度也比操作数组快很多。
因为关于8皇后这一经典问题,网上有很多代码,其中有一些质量比较高,很有参考价值,所以在这里就不再赘述(很好,隐藏我的丑陋代码成功)。
二、算24
这个NOI问题是这样的:
总时间限制: 3000ms 内存限制: 65536kB 描述 给出4个小于10个正整数,你可以使用加减乘除4种运算以及括号把这4个数连接起来得到一个表达式。现在的问题是,是否存在一种方式使得得到的表达式的结果等于24。 这里加减乘除以及括号的运算结果和运算的优先级跟我们平常的定义一致(这里的除法定义是实数除法)。 比如,对于5,5,5,1,我们知道5 * (5 – 1 / 5) = 24,因此可以得到24。又比如,对于1,1,4,2,我们怎么都不能得到24。 输入 输入数据包括多行,每行给出一组测试数据,包括4个小于10个正整数。最后一组测试数据中包括4个0,表示输入的结束,这组数据不用处理。 输出 对于每一组测试数据,输出一行,如果可以得到24,输出“YES”;否则,输出“NO”。 样例输入 5 5 5 1 1 1 4 2 0 0 0 0 样例输出 YES NO
乍一看,好像挺简单……于是,拿过来就写:挨个拿出来和之前的结果运算(开始时把其中1个看作上次结果)、包括颠倒和括号,嘿……只得了5分。然后才想起来仔细分析题目,汗颜啊。这里包括这样一些运算过程:
只表示算式形式,而不代表顺序,逗号通配运算符:
a,b,c,d
(a,b),c,d
a,(b,c),d
a,b,(c,d)
(a,b,c),d
a,(b,c,d)
以及
(a,b),(c,d)
最后一种形式就是第一次5分的问题所在。”一个一个喂“的想法,导致最后一种形式被漏掉了。好吧,老老实实的:每次取2个得到一个计算结果,把计算结果作为一个待选运算数进行迭代:
//递归计算过程 void msearch(double val[],int valcnt){ if(flag){ //找到一种得出24的路径之后不再搜索其他。 return; } if(valcnt==1){ //当只剩下一个数组元素,说明已经全部合并完成,这个元素就是最终结果。 if(abs(val[0]-24.0)<=1e-13){ //当结果等于24时,设置全局标记以结束递归。 flag=true; } }else{ //当数组元素有两个以上时,进行遍历,两两合并。并且把合并结果写入数组。 int i,j; for(i=0;i<valcnt;i++){ //用两个循环遍历全部的两两组合(参照冒泡法排序)。 for(j=i+1;j<valcnt;j++){ //依次进行加减乘除以及减、除操作数的交换 nextval(val,valcnt,i,j,val[i]+val[j]); nextval(val,valcnt,i,j,val[i]-val[j]); nextval(val,valcnt,i,j,val[i]*val[j]); if(val[i]!=0){ nextval(val,valcnt,i,j,val[i]/val[j]); } nextval(val,valcnt,i,j,val[j]-val[i]); if(val[j]!=0){ nextval(val,valcnt,i,j,val[j]/val[i]); } } } } }
这里有一行比较辣眼睛的代码:
if(abs(val[0]-24.0)<=1e-13){ //当结果等于24时,设置全局标记以结束递归。
如果换成1e-14还是5分……嘿,在我信心满满再次提交的时候,又是5分,于是对识海各种打补丁,结果……还是5分。死马当活马医吧,看看是不是精度问题:根据题意,最小值能到多少呢?3个10,1个1,1/10/10/10……之后进行若干次测试,这个”等于24“的阈值属于区间(0.001,1e-14]。这是动摇我对double精度的认识吗,以前写1e-15都没问题的?希望有大神指点一下。
好像没看见递归,为了在有限的C++知识范围内简化代码,我把它写到了nextval这个过程中,以下是算24的完整代码:
#include<iostream> #include<cstring> #include<cmath> using namespace std; bool flag; void nextval(double val[],int valcnt,int idx1,int idx2,double lastresult); //递归计算过程 void msearch(double val[],int valcnt){ if(flag){ //找到一种得出24的路径之后不再搜索其他。 return; } if(valcnt==1){ //当只剩下一个数组元素,说明已经全部合并完成,这个元素就是最终结果。 if(abs(val[0]-24.0)<=1e-13){ //当结果等于24时,设置全局标记以结束递归。 flag=true; } }else{ //当数组元素有两个以上时,进行遍历,两两合并。并且把合并结果写入数组。 int i,j; for(i=0;i<valcnt;i++){ //用两个循环遍历全部的两两组合(参照冒泡法排序)。 for(j=i+1;j<valcnt;j++){ //依次进行加减乘除以及减、除操作数的交换 nextval(val,valcnt,i,j,val[i]+val[j]); nextval(val,valcnt,i,j,val[i]-val[j]); nextval(val,valcnt,i,j,val[i]*val[j]); if(val[i]!=0){ nextval(val,valcnt,i,j,val[i]/val[j]); } nextval(val,valcnt,i,j,val[j]-val[i]); if(val[j]!=0){ nextval(val,valcnt,i,j,val[j]/val[i]); } } } } } //用取出两个数并加入结果的新数组继续递归 void nextval(double val[],int valcnt,int idx1,int idx2,double lastresult){ int i,newvalidx=0; double newval[valcnt-1]; //新数组会比原来的少1(两个操作数标为一个结果) for(i=0;i<valcnt;i++){ //把没用到的复制到新数组 if(i!=idx1 && i!=idx2){ newval[newvalidx]=val[i]; newvalidx++; } } newval[newvalidx]=lastresult; //把结果加入新数组 msearch(newval,newvalidx+1); //用新数组递归搜索 } int main(){ double val[4]; while(true){ cin>>val[0]>>val[1]>>val[2]>>val[3]; if(val[0]+val[1]+val[2]+val[3]==0){ break; } flag=false; msearch(val,4); if(flag){ cout<<"YES"<<endl; }else{ cout<<"NO"<<endl; } } }