从一道NOI练习题说递推和递归
一、递推:
所谓递推,简单理解就是推导数列的通项公式。先举一个简单的例子(另一个NOI练习题,但不是这次要解的问题):
楼梯有n(100 > n > 0)阶台阶,上楼时可以一步上1阶,也可以一步上2阶,也可以一步上3阶,编程计算共有多少种不同的走法。
这个问题可以用递归来进行解决,但是解题时间1秒明显不够用。怎么办呢,可以考虑找到“规律”,然后推导公式解决问题,开始画图分析:
这是4个台阶时的全部7种走法,记作f(4)=7。现在观察右侧绿色走过的部分,1234四种情况是3个台阶时的4种走,法记作f(3)=4;56两种情况是2个台阶时的2种走法,记作f(2)=2;7是一个台阶时的1种走法,记作f(1)=1。即:
f(4)=f(3)+f(2)+f(1)。
那么,现在把台阶数增加到5。思考一下,左面再有一个台阶时是什么情况:
1、若先走3步,则剩下2个台阶,于是有先走3阶时(剩余2阶)的走法数与f(2)相等。
2、若先走2步,则剩下3个台阶,于是有先走2阶时(剩余3阶)的走法数与f(3)相等。
3、若先走1步,易推出:剩余4阶,走法数与f(4)相等。
f(5)的全部情况遍历完毕,于是有:
f(5)=f(2)+f(3)+f(4)
f(n)可以做相同推理,可以得到数列的通项公式:
当0<n<3时: f(1)=1 f(2)=2 f(3)=4 当n>3时: f(n)=f(n-1)+f(n-2)+f(n-3)
得到通项公式之后,这个问题就非常简单了。用递推时,程序得出结果速度要比递归快,不过需要烧掉的脑细胞比较多……
二、递归
所谓递归函数,就是在函数内部调用自己。那么,递归解决问题时,可以更好的把着眼点放在问题的相同部分。即递归能解决那些一个问题可以分解成若干个子问题,再分解成若干子问题……而且这些父问题和子问题都是相同问题的问题;就是这些儿子和儿子的儿子和儿子的儿子的儿子……子子孙孙无穷尽也……但是都和隔壁老王没关系的意思。咳咳,来看我们要解决的这个NOI练习题:
总时间限制: 2000ms 单个测试点时间限制: 1000ms 内存限制: 131072kB 描述 对于一个2行N列的走道。现在用1*2,2*2的砖去铺满。问有多少种不同的方式。 下图是一个2行17列的走道的某种铺法。
(注:上图来源于http://noi.openjudge.cn/) 输入 整个测试有多组数据,请做到文件底结束。每行给出一个数字N,0 <= n <= 250 输出 如题 样例输入 2 8 12 100 200 样例输出 3 171 2731 845100400152152934331135470251 1071292029505993517027974728227441735014801995855195223534251
先分析一下这个题目:
1、可以竖着铺1*2的砖
2、如果横着铺了一个1*2,那么下面一定是1*2
所以,情况只有3种:竖着铺1*2、横着铺2个1*2、铺2*2。
那么递归调用很容易构建:
1、若当前已铺统计完全铺完的个数。
2、若当前没有铺完:剩余1格时,只能铺1*2;其他情况还有另外两种选择——横铺1*2和铺2*2。
代码如下:
void recursive(int curlen){ if(curlen==maxlen){ addcnt(); }else{ recursive(curlen+1); if(maxlen-curlen>1){ recursive(curlen+2); recursive(curlen+2); } } }
前面已经说明了为什么有两次相同调用:横着铺1*2和铺2*2都是向右两格。看起来万事大吉了。但是根据以前写五子棋引擎的经验,想要搜索二百五这么多的层,即使每个节点只有两个子节点也是耗时无数的,更不用说这基本都是3节点了。题目限定时间内完全不可能解完。于是,运行了一下n=100尝试,果然行一次五谷轮回之事回来之后我的4590还在忙于计算n+1=?的问题,当然,绝大部分时间都浪费在进出栈上面了。接下来,就是递归改递推来化腐朽为神奇的时刻了,稍微修改代码,让它输出n较小时的一些结果以便观察数据规律:
#include<iostream> #include<cstring> using namespace std; int maxlen,cnt[250],cntidx=0,k=10000; void addcnt(){ cnt[0]++; for(int i=0;i<=cntidx;i++){ if(cnt[i]>=k){ cnt[i]=cnt[i]%k; cnt[i+1]++; }else{ break; } } if(cnt[cntidx+1]==1){ cntidx++; } } void recursive(int curlen){ if(curlen==maxlen){ addcnt(); }else{ recursive(curlen+1); if(maxlen-curlen>1){ recursive(curlen+2); recursive(curlen+2); } } } int main(){ for(int j=1;j<15;j++){ maxlen=j; memset(cnt,0,sizeof(cnt)); recursive(0); printf("%2d %d",j,cnt[cntidx]); for(int i=cntidx-1;i>=0;i--){ printf("%04d",cnt[i]); } printf("\n"); } }
输出是这样的:
1 1 2 3 3 5 4 11 5 21 6 43 7 85 8 171 9 341 10 683 11 1365 12 2731 13 5461 14 10923 请按任意键继续. . .
哦吼,还是挺明显的,后一项都差不多是前一项*2。好吧,不能差不多,当n为偶数时:f(n)=f(n-1)*2+1,当n为奇数时:f(n)=f(n-1)*2-1。虽然没有画图(估计就我这识海这么多窟窿,画也画不明白)来分析,但是这个公式应该可信度还是很高的…………权当是它吧。
到这里问题的思路就理清了。不过问题也来了,这个通项公式里面有加、减、乘三种运算,还需要写这几个函数。最好还是写函数吧,易读易维护。感叹一下,NOI里面有一个提问功能,类似论坛,有一些热心的学生(从言行表现的蓬勃朝气看应该是)针对问题提供一些答案和题目的“坑”,不过不仅论坛本身的排版不好影响阅读,而且绝大多数编码都在一个函数里面,变量命名惜字如金,导致阅读非常困难,可能是青年学生和他们的授业者脑力好吧,不过这些都绝对是不可取的编程方式,那怕是再小的代码,除去约定俗成的ijk……这些用来少次数嵌套循环的变量外,都应该能够顾名思义。首先,来写这几个大数加减乘法,其中省略了操作数2,因为它们是固定的1、1、2,权当复习一下前几天写的大数加减法代码:
int add(int v[],int cnt){ v[0]++; int i,tmp; for(i=0;i<=cnt;i++){ tmp=v[i]; if(tmp>=k){ v[i]=tmp%k; v[i+1]+=1; }else{ break; } } if(v[cnt+1]==1){ cnt+=1; } return cnt; } int sub(int v[],int cnt){ v[0]--; int i,tmp; for(i=0;i<cnt;i++){ tmp=v[i]; if(tmp<0){ v[i]=tmp+k; v[i+1]-=1; }else{ break; } } if(cnt>0 && v[cnt]==0){ cnt-=1; } return cnt; } int mult(int in[],int incnt,int out[]){ int i,tmp; for(i=0;i<=incnt;i++){ out[i]=in[i]*2; } for(i=0;i<=incnt;i++){ tmp=out[i]; if(tmp>=k){ out[i]=tmp%k; out[i+1]+=1; } } if(out[incnt+1]!=0){ incnt++; } return incnt; }
咳咳,好像我用了保留字作为形参名。新手嘛,嘿,下次不了。其中k表示进制,使用的是万进制。加减法都是直接修改数组,乘法是另存新数组,因为要根据n-1时的值来计算n时的值保留在数组里以备输出,所以不能覆盖。这几个函数的返回值都是运算完成时做返回值用的数组的元素的最大下标。
然后就是开始循环计算了:
data[1][0]=1; for(i=2;i<251;i++){ lintlen=mult(data[i-1],lintlen,data[i]); if(k==1){ lintlen=add(data[i],lintlen); }else{ lintlen=sub(data[i],lintlen); } data[i][lintlenbit]=lintlen; k*=-1; }
这里在每个表示大数的数组(n=250时用到20个int)结尾又增加了一个int,用来记录用该数组中用到的最大下标,所以初始定义了每个表示大数的数组有21个元素。另外,为了编码简单,data数组下标开始于1以对应输入的n。不过后来data[0]还真的用到了,因为这个题评分时存在一个问题:n=0时应输出1。让我们来思考一下,这是不是代表往一个长度为0的走道里铺砖呢,然后没法铺的铺法个数为1种:没法铺。这个问题在现在:2016-12-24 22:55确实存在,得分为6分:
以后会不会修改就不得而知了。可以在下面完整代码中修改data[0][0]=1;一行为data[0][0]=0;测试。另一个问题就是不知道为什么,提交后显示成功,但只有9分。另:根据这个题目提交之后运行时间只有1ms是不是可以推测,评测端当输入输出流正常相应时开始计时,而不是从进程启动开始,很明显这比较合理。有没有漏洞呢?例如用递归计算的代码来代替递推计算的代码,那么递归的时间不被计算在内,当用递归初始化完data[][]之后,同样用查询来输出答案,是不是也可以不超时呢?答案是否定的,看看我后来提交的那个89b的死循环就知道了,也许它证明了前面的猜测是错误的,只是评测端比较快:)
最后,就是这份完整的代码了:
#include<iostream> #include<cstring> using namespace std; const int k=10000; const int lintlenbit=20; //data[250]用了19个int,20位用来保存数据长度。 int data[251][lintlenbit+1]; int add(int v[],int cnt){ v[0]++; int i,tmp; for(i=0;i<=cnt;i++){ tmp=v[i]; if(tmp>=k){ v[i]=tmp%k; v[i+1]+=1; }else{ break; } } if(v[cnt+1]==1){ cnt+=1; } return cnt; } int sub(int v[],int cnt){ v[0]--; int i,tmp; for(i=0;i<cnt;i++){ tmp=v[i]; if(tmp<0){ v[i]=tmp+k; v[i+1]-=1; }else{ break; } } if(cnt>0 && v[cnt]==0){ cnt-=1; } return cnt; } int mult(int in[],int incnt,int out[]){ int i,tmp; for(i=0;i<=incnt;i++){ out[i]=in[i]*2; } for(i=0;i<=incnt;i++){ tmp=out[i]; if(tmp>=k){ out[i]=tmp%k; out[i+1]+=1; } } if(out[incnt+1]!=0){ incnt++; } return incnt; } void printdata(int v[],int cnt){ printf("%d",v[cnt]); for(int j=cnt-1;j>=0;j--){ printf("%04d",v[j]); } printf("\n"); } int main(){ int lintlen=0,n=0,i,k=1; data[0][0]=1; data[1][0]=1; for(i=2;i<251;i++){ lintlen=mult(data[i-1],lintlen,data[i]); if(k==1){ lintlen=add(data[i],lintlen); }else{ lintlen=sub(data[i],lintlen); } data[i][lintlenbit]=lintlen; k*=-1; } while(scanf("%d",&n)!=EOF){ printdata(data[n],data[n][lintlenbit]); } }