ACM这门课是在同学的聊天中了解的,当时感觉自己应该没什么问题,很有勇气的报了这门课,但随着学习的深入,突然发现不是这么一回事,发现这门课的难度对我来说史无前例,不管是高等数学,还是其他的一些科目,难度都远远不及,可是既然来了,就不能放弃,就这么一步一步的坚持了下来,到现在也是非常有收获,可能很多人会怀疑这门课的实用性,但我认为,它带给我们的思考的方式是无与伦比的,尽管只有这一学期的学习,但在费玉奎老师的教学下,也让我收获颇丰,同时,让我初步了解了算法这个东西,对以后的编程也会有很大的帮助,不管如何,我还是坚持下来了,尽管ACM队员的报名我可能不会参加,但这一学期的学期,会让我永远受益。
下面来浅谈一下我们学到的一些算法:
(1)第一个专题贪心专题,贪心算法个人的理解是本算法总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。贪心算法不是对所有问题都能得到整体最优解,但对范围相当广泛的许多问题他能产生整体最优解或者是整体最优解的近似解。我认为他是循环结构的延伸,所谓的for+if的形式通过某种度量来找出当前的最优在用此度量找到所有最优解的最优。这个专题刷题中印象比较深的几个问题莫过于背包问题,活动安排问题,钓鱼问题,单纯策略的田忌赛马问题。
使用贪心算法求解问题应该考虑如下几个方面:
(1)候选集合A:为了构造问题的解决方案,有一个候选集合A作为问题的可能解,即问题的最终解均取自于候选集合A。
(2)解集合S:随着贪心选择的进行,解集合S不断扩展,直到构成满足问题的完整解。
(3)解决函数solution:检查解集合S是否构成问题的完整解。
(4)选择函数select:即贪心策略,这是贪心法的关键,它指出哪个候选对象最有希望构成问题的解,选择函数通常和目标函数有关。
(5)可行函数feasible:检查解集合中加入一个候选对象是否可行,即解集合扩展后是否满足约束条件。
背包问题相对思想比较简单,有两类背包问题(根据物品是否可以分割),如果物品不可以分割,称为0—1背包问题(动态规划);如果物品可以分割,则称为背包问题(贪心算法)。
此类问题有3种方法来选取物品:
(1)当作0—1背包问题,用动态规划算法,获得最优值220;
(2)当作0—1背包问题,用贪心算法,按性价比从高到底顺序选取物品,获得最优值160。由于物品不可分割,剩下的空间白白浪费。
(3)当作背包问题,用贪心算法,按性价比从高到底的顺序选取物品,获得最优值240。由于物品可以分割,剩下的空间装入物品3的一部分,而获得了更好的性能。
本质:按照某种条件排序,然后再根据限制条件来进行选择。
田忌赛马问题,本类问题逻辑比背包问题复杂(选取策略种类多,情况多),但是基本思想还是贪心,选取当前的最优策略。这种题目主要考审题:
(1)分析好策略种类
(2)分析好各个策略的选定条件;
(3)循环求解。
多处最优服务次序问题
对服务时间最短的顾客先服务的贪心选择策略。
首先对需要服务时间最短的顾客进行服务,即做完第一次选择后,原问题T变成了需对n—1个顾客服务的新问题T’。
新问题和原问题相同,只是问题规模由n减小为n—1。
基于此种选择策略,对新问题T’,在n—1个顾客中选择服务时间最短的先进行服务,如此进行下去,直至所有服务都完成为止。
删数问题本问题采用下降点优先的贪心策略
(1)虽然删去1位数后,把原问题T变成了n-1位中删除k-1位的问题
(2)循环继续
(2)二分算法与三分算法
二分算法相对于三分算法来说简单一点,下面我们先来看看这个二分算法。
对于二分算法也叫做二分查找,二分查找又称折半查找,优点是比较次数少,查找速度快,平均性能好;其缺点是要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。
二分算法的数学模型就是单调函数求零点。下面为大家介绍一个多人分披萨例题:
简单题意:有多个面积不相等的披萨,分给多个人,要求每个人拥有的披萨面积相同,并且不能重组。先输入x和y为披萨和人的个数,之后输入x个披萨的半径,求每个人所能拥有的最大面积。
思路形成:采用二分法,先记录所有面积的总和作为最大值max,最小值为零min=0,求中间值mid,然后将所有的披萨计算这个面积所能满足的人数和真实人数作比较,之后再用二分法的基本方法计算即可。(其中最重要的是当计算满足的人数时,应该将人数类型设为整形)。
代码如下:
#include<iostream>
#include <cstdio>
#include<algorithm>
using namespace std;
double pai=acos(-1.0);
int main()
{
int n,m,f,i,p,pp;
double k,size[10000],low,hight,sum,mid;
cin>>n;
while(n--)
{
sum=0;
cin>>m>>f;
for(i=1;i<=m;i++)
{
cin>>k;
size[i]=k*k*pai;
sum+=size[i];
}
i=0; low=0; hight=sum/f; p=0;
while(1)
{
p=0;
mid=(low+hight)/2; //二分法中间值
for(i=1;i<=m;i++)
{
pp=size[i]/mid;//将pp设为整形
p+=pp;
}
if(p>f)
low=mid; //二分法的 基本代码
else
hight=mid;
if((hight-low)<0.00001)
{
mid=(hight+low)/2;
cout<<fixed<<setprecision(4)<<mid<<endl;
break;
}
}
}
}
二分法的基本代码:mid=(low+hight)/2; if(p>f)
low=mid;
hight=mid;
if((hight-low)<0.00001)
{
mid=(hight+low)/2;
cout<< <<endl;
break;
}
对于三分算法就是解决凸形或者凹形函数的极值的方法,mid = (Left + Right) / 2
midmid = (mid + Right) / 2如果mid靠近极值点,则Right = midmid;否则(即midmid靠近极值点),则Left = mid。
三分法的模板如下:
double cal(Type a)
{
/* 根据题目的意思计算 */
}
void solve()
{
double Left, Right;
double mid, midmid;
double mid_value, midmid_value;
Left = MIN; Right = MAX;
while (Left + EPS <= Right{
mid = (Left + Right) / 2;
midmid = (mid + Right) / 2;
if (cal(mid)>=cal(midmid))
Right = midmid;
else Left = mid; }
}
三分法应用的不多,就不再举例题了。
(3)
第二个专题就是搜索了,很系统的分为深度搜索和广度搜索了。我在大一时候做题吧,经常遇到地图的题目,什么迷宫啊,在地图上拣钱啊,逃出升天啊,在最短时间内救出公主啊等等啥的,那都是天文感觉,完全摸不着头脑,根本不知道如何下手。而搜索专题就是针对地图来的就好像,把一个个问题分解,如何逐一解决,最后找到答案,虽然很繁琐,但确实把答案找到了,而且经过长时间的接触地图类的题目,感觉其实都是千篇一律,都有套路在里面,没什么新鲜感了,唯一有的说的,就是根据题目本身的意思来增加一系列的枝丫限制了,达到搜索出答案的目的。
先说说深度搜索吧,根据经验,其实最实用,最普遍,最简单的应该就是它了。它从某个状态开始,如何根据题意创建函数来递归。它的效率其实不是很高,因为递归调用本身就本身很高,但深度搜索为了弥补这一缺点,可以根据题意通过剪枝限制条件来限制一些在一开始看起来就没有答案的路径,直接给判个死刑,避免再继续沿这条道路走下去而浪费时间了。搜索这类题目,隐蔽性很低的,小白都可能知道你这是考的啥,用深度搜索或者广度搜索来解决,直接套路就OK了。那么难点在哪里呢?题目不可能会那么简单的,否则还考你啥呀,直接给你分数不是更直接。地图题目考搜索考的是剪枝!对,就是对于题目本身的了解来对搜索方法进行限制,如果限制的不到位,很大可能直接超时,通过你的代码,考研找到答案,只是时间要的久点罢了,那也是失败的。最最关键的是对题目的把握,找到题目的隐含条件,来增加剪枝限制,减少时间的消耗。这点是最关键的了。
再谈谈广度搜索吧,广度搜索和队列相配合,比深度搜索要麻烦点,而且也不太实用,限制性比较大,主要在题目问有没有最优,最小,最多,最合理什么的用到这个方法,而且套路也很固定。主要是取状态,看是否合法,合法就加入队列,然后取头,删头,判断下一状态,合法再加入等等,直到队列为空为止。
地图的题目有的看不出来需要用深度搜索还是广度搜索,就有可能都可以,或者隐蔽性好,首先先选择深度搜索,比较简单,也普遍实用,如果不能实现问题,那么再换广度搜索也不迟。其实在我看来,广度搜索也是一种另类的递归调用,只是比较隐蔽,用队列来隐藏了本来面目。深度搜索和广度搜索,是两种方法的不同道路的搜索,就像一千个读者就有一千个哈默雷特一样,不同的人搜索地图的时候不一定次序相同,但结果都是一样的,主要是是看根据实际来选择最优的方法罢了。人都有惰性,选择最好的最省事的那个才是最佳的了吧。
地图搜索在现实中,类似于满大街找人,看你选择的方法怎样最省事的找到就行。地图搜索见的比较多的,应该在游戏里,许多任务都是走迷宫找宝藏什么的。或许还有更大的发现类似于地图搜索在等着我去寻找,我想既然拥有了搜索思维,以后总会有它的用武之地,艺多而不压身。
以下是广度搜索的示例框架:
While Not Queue.Empty ()
Begin
可加结束条件
Tmp = Queue.Top ()
从Tmp循环拓展下一个状态Next
If 状态Next合法 Then
Begin
生成新状态Next
Next.Step = Tmp.Step + 1
Queue.Pushback (Next)
End
Queue.Pop ()
End
以下是深度搜索的示例框架:
递归实现:
Function Dfs (Int Step, 当前状态)
Begin
可加结束条件
从当前状态循环拓展下一个状态Next
If 状态Next合法 Then
Dfs (Step + 1, Next ))
End
非递归实现:
While Not Stack.Empty ()
Begin
Tmp = Stack.top()
从Tmp拓展下一个未拓展的状态Next
If 没有未拓展状态(到达叶节点) Then
Stack.pop()
Else If 状态Next合法 Then
Stack.push(Next)
End
(4)
第三个专题就是动态规划了。其实这个专题的时候,做题很是矛盾。怎么说呢,简单题,水的一塌糊涂,难题,看的两眼直发呆,对,就是这两种极端。我记得老师讲动态规划的时候是以最长上升子序列入手,然后是各种改头换面的上升子序列问题,那节课的结尾是以最简单的一种背包问题收尾的。再下节课就是各种的背包问题了,我当时已然懵了,都只顾看代码了,都没怎么注意题名,现在都记不太清楚具体的什么背包问题了。其实上动态规划完,我是兴奋的,第一次发现数组可以那样子用,感觉一下子刷新了我对数组的认识一样。以前二维数组,都是表示个表啊,地图啥的,都是二维平面的东西,老师讲的将2个下标分解开用,感觉很是新鲜,解决这类问题也确实收到了奇效,当然,也对像我这样的初学者不仅对于数组,一定还有一些我未发现的直接常用的事物的不同用法,我深信它们存在,这对于初学者来说是个考研,也是福音,多去发现,可能不知不觉直接已经是大神级别的编程师了。
动态规划,难在规划上。对于专题内的题目来说,迷惑性趋向于0。但在专题外,就像老师说的,看完题目,很难向动态规划上去想。所以会觉得很难很难,但一旦想到可能是动态规划的问题,那么基本上答案就呼之欲出了。动态规划是难,难在题干,难在迷惑性强。动态规划又很简单,单一的抽象,单一的方法,单一的循环等等,所以可以这么说:如果一道迷惑性很强的动态规划题,你恰好想到了可能是动态规划并动手去试,那么其实你已经把这题解决了。动态规划拥有很固定的套路,只要按照套路,然后再根据题意修改一下细节,完全是没有难度的。
动态规划在现实中是无处不在的,否则题目也不会有那么强的迷惑性。在现实中解决问题时候,一旦感觉无从下手,试试动态规划的策略模式,往往可能一击中敌,得到答案。
动态规划问题一般的递推关系式:
F[a][b]=max(F[a-1][b],F[a][b-1])+Coin[a][b]
(此步即为递归定义最优解的值,列出状态转移方程)
动态规划问题的一般解题步骤
1、判断问题是否具有最优子结构性质,若不具备则不能用动态规划。
2、把问题分成若干个子问题(分阶段)。
3、建立状态转移方程(递推公式)。
4、找出边界条件。
5、将已知边界值带入方程。
6、递推求解。
最长上升子序列例题的示例代码如下:
$include<iostream>
Uisng namespace std;
int b[MAX_N + 10];
int aMaxLen[MAX_N + 10];
int main()
{
int i, j, N;
scanf("%d", & N);
for( i = 1;i <= N;i ++ )
scanf("%d", & b[i]);
aMaxLen[1] = 1;
for( i = 2; i <= N; i ++ )
{ //求以第i 个数为终点的最长上升子序列的长度
int nTmp = 0; //记录第i 个数左边子序列最大长度
for( j = 1; j < i; j ++ )
{ //搜索以第i 个数左边数为终点的最长上升子序列长度
if( b[i] > b[j] )
{
if( nTmp < aMaxLen[j] )
nTmp = aMaxLen[j];
}
}
aMaxLen[i] = nTmp + 1;
}
int nMax = -1;
for( i = 1;i <= N;i ++ )
if( nMax < aMaxLen[i])
nMax = aMaxLen[i];
printf("%d\n", nMax);
return 0;
}
(主要是递推关系式,做题模版很容易被套用,而后求解)
(5)第四个专题图论
图论〔Graph Theory〕是数学的一个分支。它以图为研究对象。图论中的图是由若干给定的点及连接两点的线所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系,用点代表事物,用连接两点的线表示相应两个事物间具有这种关系。
在这一专题,我学到了有向图,无向图,完全图等概念,也学习了并查集,最小生成树,单源最短路径等问题的解决思路,学会了Prim,kruskal,Dijkstra等算法,也适当的了解了一些其他的算法
图论,是ACM程序设计这门课的最后一个专题,我觉得也是最难的一个专题,上述所提及的算法,只是图论中算法的极小一部分,图论的综合性比较强,比如有的图论题甚至会用到贪心或搜索的思想,所以遇到具体的问题应该具体的对待。
图论题的另一个特点是模板性非常强,对于常用的算法,只要将其编写成函数,遇到同类问题,只要将现成的函数复制进去,并且修改一下mian()函数和输入输出格式,就可以正确的得出结论。
我觉得,图论的知识在以后的工作中应用性比较强,例如在道路修建,铁路调度,电子地图制作,打车软件等领域,图论的部分思想渗透其中,所以说,学好图论很重要!
先说明两个图的存储方式邻接矩阵和邻接表,邻接矩阵的使用场合为:数据规模不大n <= 1000,m越大越好、稠密图最好用邻接矩阵、图中不能有多重边出现。
邻接表的基础代码为struct edge
{
int x, y, nxt; typec c;
} bf[E];
void addedge(int x, int y, typec c)
{
bf[ne].x = x; bf[ne].y = y; bf[ne].c = c;
bf[ne].nxt = head[x]; head[x] = ne++;
}
并查集:将编号分别为1…N的N个对象划分为不相交集合,在每个集合中,选择其中某个元素代表所在集合。常见两种操作:合并两个集合,查找某元素属于哪个集合。
最小生成树问题之Prim算法:
基本思想:任取一个顶点加入生成树;在那些一个端点在生成树里,另一个端点不在生成树里的边中,取权最小的边,将它和另一个端点加进生成树。重复上一步骤,直到所有的顶点都进入了生成树为止。
基本内容:设G=(V,E)是连通带权图,V={1,2,…,n}。构造G的最小生成树的Prim算法的基本思想是:首先置S={1},然后,只要S是V的真子集,就作如下的贪心选择:选取满足条件iÎS,jÎV-S,且c[i][j]最小的边,将顶点j添加到S中。这个过程一直进行到S=V时为止。在这个过程中选取到的所有边恰好构成G的一棵最小生成树。
基础代码:int prim(int n,int mat[][MAXN],int* pre){
int min[MAXN],ret=0;
int v[MAXN],i,j,k;
for (i=0;i<n;i++)
min[i]=inf,v[i]=0,pre[i]=-1;
for (min[j=0]=0;j<n;j++){
for (k=-1,i=0;i<n;i++)
if (!v[i]&&(k==-1||min[i]<min[k]))
k=i;
for (v[k]=1,ret+=min[k],i=0;i<n;i++)
if (!v[i]&&mat[k][i]<min[i])
min[i]=mat[pre[i]=k][i];
}
return ret;
}
最小生成树问题之Kruskal算法:
基本思想:将边按权值从小到大排序后逐个判断,如果当前的边加入以后不会产生环,那么就把当前边作为生成树的一条边。最终得到的结果就是最小生成树。并查集。
基本内容:把原始图的N个节点看成N个独立子图;每次选取当前最短的边,看两端是否属于不同的子图;若是,加入;否则,放弃;循环操作该步骤二,直到有N-1条边;一维数组,将所有边按从小到大的顺序存在数组里面先把每一个对象看作是一个单元素集合,然后按一定顺序将相关联的元素所在的集合合并。能够完成这种功能的集合就是并查集。对于并查集来说,每个集合用一棵树表示。它支持以下操作:Union (Root1, Root2) //合并两个集合;Findset(x) //搜索操作(搜索编号为x所在树的根)。树的每一个结点有一个指向其父结点的指针。
基础代码:void MakeSet()
{
long i;
for (i=0;i<=m;++i)
{
father[i]=i;
}
}
long Find(long i)
{
long r=i;
while (father[r]!=r)
{
r=father[r];
}
while (father[i]!=r)
{
long j=father[i];
father[i]=r;
i=j;
}
return r;
}
ACM课的学习要结束,尽管没有参加集训队,但还是会不停息的做一些题来让自己记得这么学过的东西,我想它的思想一定会让我更强于别人一步,这也是ACM的乐趣把,它不是煎熬,它是乐趣!