这篇解题报告是对我最近一些题的总结,里面的代码都是我解题,优化,再优化的过程的记录,记录了自己对算法的完善与优化思路,还有对编程哲学的理解:do it,do it well。
很感谢孙老师您,让自己可以找到利用计算机作比只是单纯玩游戏更有意义又更有趣的事情,又通过您的课,通过照着您给的路自己不断探索,感觉自身能力得到了很大的提升,从当时一个小程序漏洞百出,思路拙劣,对oj系统的不熟悉,甚至当时上课时平时的练习和考试都让自己感觉不尽人意,到现在自己不断地追求完善代码完善思路,追求完美追求最优,虽然还是拙劣但是感觉自己在努力,感觉在自己现有的知识程度上已经做的很美了,这种在心里涌上的孜孜不倦的追求的感觉让自己感觉到了人生的意义,正如歌德在《浮士德》里所表达的主题一样,完善的境界永远不可及,人类所能达到的最高成就,恰在于一种自强不息的创造性生活本身,一种不断进步的道路与过程本身。
好了,说了那么多,该切入正题了,审视自身的努力,反思自己的不足,就可以很清楚的一个菜鸟标准的特征是:耗时费力的暴力枚举浪费了大量资源,永远不变的单一的选择排序,不自觉暴露出的goto使用……所以我今天借三个同样是关于求出最优解的问题,来说明我是怎么冲出暴力枚举。
第一个问题,很简单,是ustcoj上的1389,连续子数组的和,题目大意如下:输入一个整型数组,数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。求所有子数组的和的最大值。(数组里的元素可正可负)
想都不用想,直接暴力枚举,太容易了,且只是个o(n^2)的算法,由于n小于5000,必然不用当心超时,直接用暴力枚举就可以过了,看代码:
#include <stdio.h> #define max 5000 int a[max+1]; int qiuzhi(int n) //枚举所有的情况,返回里面的最大值 { int i,j,ans,temp; ans=a[0]; for(i=0;i<n;i++) { temp=a[i]; if(temp>ans) ans=temp; for(j=i+1;j<n;j++) { temp+=a[j]; if(temp>ans) ans=temp; } } return ans; } int main() { int n,i,answer; while(scanf("%d",&n)) { if(n==0) break; for(i=0;i<n;i++) scanf("%d",&a[i]); answer=qiuzhi(n); printf("%d\n",answer); } return 0; }
解释都不必了,就是把所有的子序列的和枚举一遍求出它的最大值,看不懂的话就好好回头学好基本功。你可以写出这些代码就停了,毕竟accept万岁!深有体会,一个accept不容易啊,但你就这样放着它不管了,你就永远只会到这个水平,永远得不到提升,看看有些问题的排行榜,看看上面的大神们,有时几乎从第一名到第十名都是同一个大神,足以看出他们不满足于仅仅的accept,也足以理解他们为什么有那么强的能力。永远记住有完成,就可以更好的完成,有优就有更优。永远相信当前的方案不是最好的,为求完善孜孜不倦!
回过头来,仔细想想,对于这样一个问题,o(n^2)的时间复杂度是不是太大了,是不是可以继续缩短它运行时和所耗的空间。我们要达成这样的目的,仔细来看要,无非可以从两方面入手,一是先通过特殊处理来减少枚举的复杂度;二是减少枚举的次数。这些也是我对做过的这些题目优化过程的总结。要完成这两个方面,一即通过一些搜索、排序等增加有序度的运算来将难以办到的枚举变得简单;二即从问题条件入手通过推理找到一些隐性的条件,增加这些隐性的条件来减少枚举次数。我们来看这个问题,它说要找到所有子数组的和的最大值,这个最大值满足些什么特征呢?或者说这个和最大的子数组满足些什么特征呢?至少我们可以找到这一条特征,这个子数组里的第一个数必然不可能小于零,为方便我们不妨称之为定理一。定理一很好理解,用反证法,如果这个子数组的第一个数小于零了,去掉它的第一个数所得的子数组必然比它要大,这与它的和最大相矛盾,所以定理一成立。我们根据这个条件可以在枚举加入此条件,减少枚举次数:
for(i=0;i<n;i++)
{
if(a[i]<0)
continue;
……
}
虽然这样算法的时间变的不稳定,我不会求它的时间复杂度了,但可以肯定对于大多数时候比之前要快很多了,我们继续对它进行优化,也就是我们继续寻找隐性条件,其实对于定理一我们可以进行这样的推广:这个子数组里的从第1项加到第n项的和必不小于0,即该子数组的前n项的和必大于0。称为定理二,证明不必了,跟定理一一样。我们继续增加这个条件,减少枚举的次数:
for(i=0;i<n;i++) { if(a[i]<0) continue; temp=a[i]; if(temp>ans) ans=temp; for(j=i+1;j<n;j++) { temp+=a[j]; if(temp<0) { i=j; continue; } if(temp>ans) ans=temp; } }
代码运算速度继续得到加快,特别是当temp<0时,i=j,直接跳过的不必要枚举又有了很多,我们继续想想还可以继续优化吗?还有隐式条件吗?我们继续分析,不难得到这点,即我们得到最大值的那个数列必然满足这点:在满足定理二的条件下(满足定理二必满足定理一),尽可能多的数相加得到的值最大,这个显而易见的,对于特定的数组,它的每个数都大于0的话,最大值子数列即它本身,即满足当前条件下尽可能多的数相加得到的值最大,这个隐式条件有什么用呢?根据它实际我们可知不必枚举所有的子数组的值,我们只需枚举可以去到最多的满足条件的数的数组就可以求出最大值,根据这个我们只需这样进行一次枚举即可:
int qiuzhi(int n) { int i,ans,temp=0; ans=a[0]; for(i=0;i<n;i++) { temp+=a[i]; if(temp>ans) ans=temp; if(temp<0) temp=0; } return ans; }
于是乎,我们的算法的时间复杂度从o(n^2)降到了o(n),优化优化再优化,冲破单一暴力枚举,你的能力得到提升。到了这样速度已经很好了,但是不要满足不要逗留,就像浮士德的对逝去的瞬间发出的那句“逗留一下吧,你是那样美!”,之后等待他的就是万劫不复的地狱。孜孜不倦的渴求,勇往直前的奋进,才能让人永远激昂,永远感受到青春的力量,永远立于不败!
这样之后,我们实际上还可以进行优化,精益求精,因为定理一和定理二可以这样推广:对于第一个数开始,和最后一个数开始同样都是满足的,所以扫描可以从两头同时开始,这样我们在部分情况下可以节省部分时间,但是这么做造成了双倍的空间,和几个多出来的对中部的判断情况进行判断,这样又造成了额外的开支,使这种优化变得不必要。我尝试了几次对这个思路进行特殊处理后来继续优化,但都失败了,就不好意思把不成熟的代码拿出来。
第二个问题,是ustcoj上的1353切绳子,题目大意如下:开始时你手上有一根长为正整数N的绳子。你选择一个长度X(1 <= X <= N-1且为整数),将绳子切成长为X和N-X两部分,得到操作分(-X^2+N*X+K)。之后,你要在切出来的两段绳子中选择一段再做同样的操作得到三段绳子。继续下去,直到你得到N段长为1的绳子为止(这时你无法再切下去了)。最终得分为你操作分的总和。如果每次切绳子时长度X为随机选择的(等概率),手里有多段绳子时切的绳子也是从可以继续切的绳子(长度大于1)中随机选择的,那么最终得分的期望值是多少?(输出结果只要整数部分)
初始解法,递归法暴力枚举,解释详见注释:
#include <stdio.h> float a[1001]={0}; //作为递归使用时加快递归算法速度的辅助储存数组。 int b[1001]={0}; float f(int n) //计算当前n下所有情况的总分 { int i; if(n>1) { if(a[n]) return a[n]; else { for(i=1;i<n;i++) { a[n]+=n*i-i*i; //题目中的表达式 a[n]+=f(n-i)+f(i); } } return a[n]; } else return 0; } int h(int n) //递归计算n下的总可能情况数 { int i; if(n>2) { if(b[n]) return b[n]; else { for(i=1;i<n;i++) b[n]+=h(n-i)+h(i); return b[n]; } } else { if(n==1) return b[1]=0; else return b[2]=1; } } int main() { int n,k,s; float g; while(~scanf("%d %d",&n,&k)) { s=h(n); //得到总的可能情况数 g=f(n)/s+(n-1)*k; //计算数学期望 printf("%.0f\n",g); } return 0; }
暴力枚举没任何技术含量,时间被大量的浪费,且计算的过程会出现越界的情况,该代码最多可以准确的算到n=60左右,再大计算过程中a[n]里存储的值超出float范围,到后面long double也装不下。于是继续进行分析,比如说,对于求n下的总情况与n之间的函数关系是否可以求出来呢?这实际上就看你的高中的数列功底如何了,这两个数学表述出来都是数列求通项的问题。
求总的情况数:记为b数列,记第n项为bn,记前n项的和为Sn,求满足以下条件的数列,b1=0,b2=1,
bn=(bn-1+b1)+(bn-2+b2)+…+(b2+bn-2)+ (b1+bn-1) …… eq \o\ac(○,1)1
求通项:
bn=2Sn-1 …… eq \o\ac(○,2)2
又因:bn=Sn-Sn-1 …… eq \o\ac(○,3)3
由 eq \o\ac(○,2)2 eq \o\ac(○,3)3可得:Sn=3Sn-1为一等比数列。又S1=0,S2=1;所以有Sn=3n-3。
代入 eq \o\ac(○,2)2有:bn=2Sn-1=2*3n-3。
对于求总的得分数有:同样记为a数列,第n项为an,记前n项的和为Sn,求满足以下条件的数列,a1=0,a2=1,
an=(an-1+a1+n*1-12)+(an-2+a2+n*2-22)+…+(a2+an-2+n*n-2-(n-2)2)+ (a1+an-1+n*n-1-(n-1)2) …… eq \o\ac(○,4)4
化简得到:Sn=3Sn-1+n*n+1*(n-1)/6 (可以左右两边同时加一个减一个,化成形如Sn+f(n)=3(Sn-1+f(n-1))这样,化成一个等比数列了再求解,求出来后通式很复杂,不写出来了),这个式子的通项很复杂,可以用左右两边加一个减一个方法化成等比数列,不过就算是求出来了,表达式过于恐怖了,不好写,于是求到这步就停止了,继续用递归往下求就行了,虽然因为数学不够好求不下去了,但比之开始又快了好多,代码如下:
#include <math.h> #include <stdio.h> double a[1001]={0}; double f(int n) { if(a[n]) return a[n]; else { if(n>2) { return a[n]=n*(n*n-1)/6+3*f(n-1); } else return 1; } } int main() { int n,k; double g; while(~scanf("%d %d",&n,&k)) { if(n>2) { g=(f(n)-f(n-1))/(2*pow(3.0,n-3))+(n-1)*k; } else { if(n==1) printf("0\n"); else g=k+1; } printf("%.0lf\n",g); } return 0; }
比开始时代码都精简了好多。
继续代入,由于bn=2*3n-3,实际算出了求出来大概an的3n-3项系数大概在10*3n-3左右,其他部分在n>=5时an/bn都是小数,由于只要求输出整数部分,所以在n>=5时可以取an/bn=5即可,对n<5时单独列表继续进行简化;又g=an/bn+(n-1)*k=n*k-k+5。
有新的简化:
#include <stdio.h> int main() { int n,k; long long g; while(~scanf("%d %lld",&n,&k)) { if(n>=5) g=n*k+5-k; else { switch(n) { case 0: case 1: g=0; break; case 2: g=k+1; break; case 3: g=2*k+3; break; case 4: g=3*k+4; break; } } printf("%lld\n",g); } return 0; }
现在算法直接变得与n无关了,也突破了暴力枚举,没办法,要命的数学,数学使用好了,什么都能迎刃而解。上题是逻辑的突破,现在这题是数学,是运算方法的突破。
第三题,是ustcoj上的1274题k_star风波,题意表达成数学语言就是:把一个长度为n的数组分为m份(m<n),保证连续且不改变顺序,使得这m个子数列和中的最大值在所有分法里面最小,输出最小值,和该分法(方便这篇文章的抒写,省去这个要求)。数组里的元素全为正整数。
现在我只分析输出最小值,实际加一个数组记录分法就可以输出了。
首先还是暴力枚举,鉴于我写的代码太长,很占篇幅,枚举就不直接放出来了,关于枚举的优化演变我在第一个问题里面假设最简单的枚举法,看这篇文章的人都会写。但是可以肯定暴力枚举必然通不过,因为是枚举次数是一个组合数C(m,n),太占时间了。
于是我优化出了一个最长时间在o((n-m)*n),最短时间在o(n)的不稳定算法。一样的寻找隐式条件,从可能最小值入手,一个个判断是不是可以成立,成立了即终止。首先可能的最小值是在该数列的最大值和最大值加它旁边的小值,假设是a[k],首先先用一个搜索算法这个搜索到这个a[k]的位置,然后判断a[k+1]和a[k-1]的大小(假设a[k-1]更小),最小值取得的范围在a[k]到a[k-1]之间,然后扫描一遍整个数组,看是否可以满足这个范围内取得最小值,如果不满足最小值取得的范围就在a[k]+a[k-1],比较a[k+1]和a[k-2]的大小(假设a[k+1]小),那么范围在a[k]+a[k-1]+a[k+1]之间了,继续扫描一遍整个数组,看能不能取得,不能继续进行以上步骤,一直进行n-m次,这部分代码如下:
......//先用搜索算法找到数组最大值位置k temp=0,min=0,maxin=0,point=0,r=l=0,fenshu,zhongzhi;// temp是记录每个部分的,min记录要输出的值,maxin记录取值范围,point记录a[k]这次是加还是减,r记录加的值,l记录减的值,fenshu记录当前已经分出的份数,zhongzhi用于判断是满足最小值范围内可以输出,满足则输出最小值。 for(i=0;i<n-m;i++) { maxin=0; zhongzhi=1; if(point) { maxin+=a[k+r]; a[k+r]=0; } else { maxin+=a[k-l]; a[k-l]=0; } if(a[k+r+1]<a[k-l-1]) //判断a[k+r+1]和a[k-l-1],确定k运行的方向(是加还是减) { r++; point=1; } else { l++; point=0; } ...... }
以上部分是扫描数组部分之前的代码,for循环内的省略部分代码如下:
fenshu=1; //初始就有包含a[k]的那份,所以fenshu=1; min=maxin; for(j=0;j<n;j++) { if(a[j]==0) //跳过包含a[k]的那份,因为开始时已计数。 { temp=0; fenshu++; j=k+r; continue; } if(point) //判断开始k是加一,还是减一 { if(temp+a[j]<=maxin+a[k+g+1]) { temp+=a[j]; if(temp>min) min=temp; } else { temp=a[j]; fenshu++; } } else { if(temp+a[j]<=maxin+a[k-l-1]) { temp+=a[j]; if(temp>min) min=temp; } else { temp=a[j]; fenshu++; } } if(fenshu>m) //要使得满足最小值在范围内,必需使分法数大于m则不成立退出 { zhongzhi=0; break; } } } if(zhongzhi) break; //fenshu<m的话即可以保证该使得最小值在范围里的分法成立,即终止,不然增加范围,开始下次循环。
以上部分即优化后的代码主要部分,算法由单纯的暴力枚举变成有选择性有条理的枚举,让一切井然有序,层层递进,算法变得更优了。
思考解决方案,设计方案基本思路、解法、算法,用某种语言将头脑里前两步所构建的算法表述出来,然后以此为基础,一步步完善,精益求精,以求尽善尽美,坚持不懈,就算路途中与千亿次失败相伴,成功也会最终慢慢光临。虽然写的代码还是很不好漏洞很多,但是从完全没有头绪,到用很拙劣的思路写出很拙劣的代码,然后再到一步步的对它们企求精美,于是思路慢慢被打开,走向一片广阔的天地,不再受困于在狭缝里挖掘,自己也就收获了很多,收获更多的是一种感觉,这种感觉别人无法体会,完善到自己的极限后,海阔天空的感觉,一份领悟,这种领悟将会使人受益终生,这也就是编程之美。
谨以此文表达对孙老师,还有两位助教给予的帮助的感谢。