这个暑假留在学校参加了ACM集训终于接近尾声了,收获很多,学到了很多算法和编程的知识,虽然非常累,但过得非常充实。
每天的训练强度非常大,从早上九点到晚上十点,一直在训练,钻研知识点,刷题,比赛,但正是这高强度的训练,感觉自己学到了很多,进步了很多,这是仅仅平时上课所不能给予的,在不断地刷题过程中,提升了自己编程的能力,也渐渐形成了自己的代码风格。
这个假期到现在为止一共打了7场比赛,从第一场手足无措一道题也没能AC,到能独立AC几道题,进步还是有的,总算没有辜负这些天的努力吧。还参加了CCPC,增长了见识,拓展了视野,至于结果,就是被虐了。下面就谈一谈具体学到的东西。
集训分了四大块专题,分别是“动态规划和背包问题”,“搜索和图论算法”,“二分法和单调队列”和“树状数组”四大块。
“动态规划和背包问题”专题是暑假前两周开的,自己着重学习了“背包问题”,终于能看懂了,要知道以前上课都听不大懂老师讲的,自己学习了“01背包问题”,“完全背包问题”,“多重背包问题”,还有“二进制优化”。把以前的一大困惑解决了,感觉很好~~~虽然运用的时候还不熟练,但收获还是很大的。
“搜索和图论算法”专题包含的东西有很多,有“DFS”,“BFS”,“图的遍历问题”,“最短路径算法”,“图的连通性问题”等等。自己着重学习了DFS,BFS,图的遍历问题,最短路径算法,下面来一一总结一下:
首先说DFS(深度优先搜索),也叫做搜索与回溯算法,要用到递归,有一句话叫做“Man understands iteration, god understands recursion”,翻译过来就是“人理解迭代,神理解递归”,递归的难度,可见一斑。刚开始接触真的很难理解,DFS是对递归的更深入的应用,代码也非常长且复杂。
深度优先算法其实就是回溯+搜索。它的基本思想是:为了求得问题的解,先选择某一种可能的情况向前探索,在探索过程中,一旦发现这一步的选择是错误的,就退回一步返回上一层次,然后重新选择继续向下探索,如此反复,直至得解或证明无解。那么什么是回溯呢?回溯法是在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题。回溯主要用递归来实现,在递归构造中,生成和检查过程可以有机结合起来,从而减少不必要的枚举。附上一道比较能深刻体现这种思想的例题:
eg.素数环
问题简述:将从1到n这20个整数围成一个圆环,若其中任意2个相邻的数字相加,结果均为素数,那么这个环就成为素数环。
#include<iostream>
#include<cmath>
using namespace std;
bool b[21]={0};
int total=0,a[21]={0};
int search(int);//回溯过程
int print();//打印结果
bool pd(int,int);//判断素数
int main()
{
search(1);
}
int search(int t)
{
int i;
for(i=1;i<=20;i++)
if(pd(a[t-1],i)&&(!b[i]))//满足条件
{
a[t]=i;//保存结果
b[i]=1;//保存结果
if(t==20)
{if(pd(a[20],a[1]))
print();}//如果到达目的地输出解
else search(t+1);/*
回溯就是让计算机自动的去搜索,碰到符合的情况就结束或者保存起来,
在一条路径上走到尽头也不能找出解,就回到上一级的岔路口,选择一条以前没有走过的路继续探测,直到找到解或者走完所有路径为止。
就这一点,回溯和所谓的DFS(深度优先搜索)是一样的。那现在的关键是,怎么实现搜索呢?
回溯既然一般使用递归来实现,那个这个递归调用该如何来写呢?
我现在的理解就是,进行回溯搜索都会有一系列的步骤,每一步都会进行一些查找。
而每一步的情况除了输入会不一样之外,其他的情况都是一致的。
这就刚好满足了递归调用的需求。通过把递归结束的条件设置到搜索的最后一步,就可以借用递归的特性来回溯了。
因为合法的递归调用总是要回到它的上一层调用的,
那么在回溯搜索中,回到上一层调用就是回到了前一个步骤。
当在当前步骤找不到一种符合条件情况时,那么后续情况也就不用考虑了,
所以就让递归调用返回上一层(也就是回到前一个步骤)找一个以前没有尝试过的情况继续进行。
当然有时候为了能够正常的进行继续搜索,需要恢复以前的调用环境。*/
b[i]=0;
}
}
int print()
{
total++;
if(total<=20) //限定打印前20个结果
{cout<<"<"<<total<<">";
for(int j=1;j<=20;j++)
cout<<a[j]<<" ";
cout<<endl;}
}
bool pd(int x,int y)
{
int k=2,i=x+y;
while(k<=sqrt(i)&&i%k!=0) k++;
if(k>sqrt(i)) return 1;
else return 0;
}
输出结果:
更多有意思的题目限于篇幅没有附上,最让我印象深刻的一道题目就是HDU1013sudoku,是用DFS得出数独的答案,相当于数独游戏的一个外挂吧。暑假里一高中同学求教一数独难题,用这个程序不到1s就解决~~~
经过将近两周的训练,到现在也大致理解了递归的原理和用法,磕磕绊绊也能做出来题了,也能看懂题解了,记得刚接触ACM的时候是连题解都看不懂的…;
然后是BFS(广度优先搜索),主要用队列来实现,这个思维量感觉比DFS小很多啊,但是代码依然很长很复杂。这种算法能解决哪类问题呢?如果目标节点的深度与“费用”(如路径长度)成正比,那么找到第一个解即为最优解,这时,搜索速度比dfs更快,再求最优解时一般采用bfs。其实广度优先搜索的核心思想是:从初始节点开始,应用算符生成第一层节点,检查目标节点是否在这些后继结点中,若没有,再用产生式规则将所有第一层节点逐一扩展,得到第二层节点,并逐一检查第二层节点中是否包含目标节点,若没有,再用算符逐一扩展到第二层的所有节点......如此依次拓展检查下去,直至发现目标节点位置。例题就不贴了,因为真的很繁琐很复杂,没有数据结构相关知识和没有学习ACM相关算法的非acmer是看不懂的,有兴趣的非acmer了解原理即可。
至于“图的遍历问题”,主要包含“一笔画问题”和“哈密尔顿环”这两个块,也是用DFS和BFS实现,不过这个“哈密尔顿环”用到了一个叫“图的邻接表存储法(又叫“链式存储法”)”,主要用链表实现,没搞懂这种方法的原理,所以“哈密尔顿环”还有点困惑。
最后是“最短路径算法”,包含的东西很多,我着重学习了Floyed算法和Dijkstra算法,都可以求关于最短路径问题,最小花费问题等等,其实还有很多别的算法,因为我的进度比较慢,没有时间看了,以后抽时间补上。
“二分法和单调队列”专题开了一个星期,二分法相比较而言还比较简单,但某些细节的处理还是很令人抓狂的,稍微漏掉一点或多了一点, wrong answer妥妥的(好像别的程序也是这样),而且最重要的是不好找出错的原因…而单调队列本身代码实现就非常麻烦了,单调队列虽然原理简单,但用代码实现过程却不容易理解,因为它的队首和队尾都可以操作,灵活性很大。
首先来看一下单调队列的原理:
•队列中元素之间的关系具有单调性(可以是单调不增加或单调不减小,也就是说可以有重复,每个元素不必严格单调),而且,队首和队尾都可以进行出队操作,只有队尾可以进行入队操作。。
•单调队列的常用操作如下:
•(1)插入:若新元素从队尾插入后会破坏单调性,则删除队尾元素,直到插入后不再破坏单调性为止,再将其插入单调队列。
•(2)获取最优(最大、最小)值:访问首尾元素。
举个例子:
一组数(1,3,2,1,5,6),进入单调不减队列的过程
1入队,得到队列(1);
3入队,得到队列(1,3);
2入队,这时,队尾的的元素3>2,将3从队尾弹出,新的队尾元素1<2,不用弹出,将2入队,得到队列(1,2);
1入队,2>1,将2从队尾弹出,得到队列(1,1);
5入队,得到队列(1,1,5);
6入队,得到队列(1,1,5,6);
“树状数组”,这个知识点更是难懂,可以说是这次集训的最难的一块内容了,结构非常抽象,实现的非常巧妙,发明这个的人简直是个天才!还是阐述一下基本的概念:
树状数组(Binary Indexed Tree(B.I.T), Fenwick Tree)是一个查询和修改复杂度都为log(n)的数据结构。主要用于查询任意两位之间的所有元素之和,但是每次只能修改一个元素的值;经过简单修改可以在log(n)的复杂度下进行范围修改,但是这时只能查询其中一个元素的值(如果加入多个辅助数组则可以实现区间修改与区间查询)。
假设数组a[1..n],那么查询a[1]+...+a[n]的时间是log级别的,而且是一个在线的数据结构,支持随时修改某个元素的值,复杂度也为log级别。
来观察这个图:
令这棵树的结点编号为C1,C2...Cn。令每个结点的值为这棵树的值的总和,那么容易发现:
C1 = A1
C2 = A1 + A2
C3 = A3
C4 = A1 + A2 + A3 + A4
C5 = A5
C6 = A5 + A6
C7 = A7
C8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8
...
C16 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8 + A9 + A10 +A11 + A12 + A13 + A14 + A15 + A16
这里有一个有趣的性质:
设节点编号为x,那么这个节点管辖的区间为2^k(其中k为x二进制末尾0的个数)个元素。因为这个区间最后一个元素必然为Ax,
所以很明显:Cn = A(n – 2^k + 1) + ... + An
算这个2^k有一个快捷的办法,定义一个函数如下即可:
| |
int lowbit(int x){ return x&-x; } | 1 2 3 |
当想要查询一个SUM(n)(求a[n]的和),可以依据如下算法即可:
step1: 令sum = 0,转第二步;
step2: 假如n <= 0,算法结束,返回sum值,否则sum = sum + Cn,转第三步;
step3: 令n = n – lowbit(n),转第二步。
可以看出,这个算法就是将这一个个区间的和全部加起来,为什么是效率是log(n)的呢?以下给出证明:n = n – lowbit(n)这一步实际上等价于将n的二进制的最后一个1减去。而n的二进制里最多有log(n)个1,所以查询效率是log(n)的。
那么修改呢,修改一个节点,必须修改其所有祖先,最坏情况下为修改第一个元素,最多有log(n)的祖先。
所以修改算法如下(给某个结点i加上x):
step1: 当i > n时,算法结束,否则转第二步;
step2: Ci = Ci + x, i = i + lowbit(i)转第一步。
i = i +lowbit(i)这个过程实际上也只是一个把末尾1补为0的过程。
对于数组求和来说树状数组简直太快了!
到此为止,大致就是集训的内容,限于篇幅,以上只是总体的阐述了一下各个专题的基本思想与概念,并没有深入探讨,深度与难度也远不及真正的比赛,因为每个专题都能出一本书啊~~~
暑假终于结束,紧接着就是迎接新学期了。继续努力吧。