首先,我想说的就是,我是一个很普通的ACMer,高中没有参加过任何计算机和数学竞赛的经历,也没有ben那样过人的天资,努力至今也未能取得什么成绩,我之所以写下这篇文章,只是希望给刚进大学或者刚进ACM队的同学一点小小的帮助,希望你们可以少走一些弯路,更希望你们可以帮助华理取得我没能取得的辉煌。
(1).起步阶段
我是从大二开始接触ACM的,要说基础的话就是大一的C语言课程了,语言方面的基础也弱,不过ACM起步阶段对于语言的要求并不是太高,只要掌握了学校C语言的课程,基本就可以开始你ACM的历程了,不过这也仅限于开始的时候,当你的ACM学到一定程度的时候,每道题的代码长度也会越来越长,你会发现一些C++的语言特性可以极大得简化你的代码长度及思路,而且C++本身就是一门非常重要的语言,啃下C++无论是对于ACM水平的提高,还是为后继Windows编程打下基础,都是有极大的帮助的。至于很多人所遇到的所谓“不知如何开头”的问题,我也想谈谈我的看法,首先你需要做的就是把PKU上一些最基础的模拟题敲一下,为什么呢?对于一个过去没有接触过编程的人来说,模拟题可以在相当程度上帮助你提高的编码能力,这里的编码能力,即将你的想法或者说是思路,在尽可能短的时间内,用尽可能优美的代码去完全正确地实现它,至于何为优美,我想在你学习的过程中你会慢慢体会到的。这里你可能要问了,去哪里找那么多模拟题呢?我想你只要在Google上搜索下“PKU 水题”之类的关键字,或者直接看下我们学校的题目分类(想要的同学可以发邮件给我问我要)就可以找到了。那么这样的题要做多少道呢?我想,对于一个初学者来说,做上大约30道~50道的简单模拟题就足够了。
我从大二的暑假开始在PKU上做题,由于那个时候主要抱着一个“玩玩”的心态,根本就没有任何比赛的打算,整个暑假边玩边学,基本上没怎么接触过算法题,也就切了几十道最简单的水题,直到暑假集训结束了也没什么长进,由于暑假最后大二的学长(那个时候我是大一)还有军训,所以我们这些大一的就全部回家了,回家之后就完全把ACM放到一边了,基本就没有碰过,这种颓废的状态一直持续到了大二开始后相当长一段时间,不知什么原因我又开始切题了(我真的忘记当初是为什么又开始切题了),并且开始接触各种基础算法了,刚开始的时候确实比较痛苦,但靠着天哥和ben牛的帮助以及上网看别人的解题报告总算摸爬滚打做了200道题左右,这个时候大二上差不多已经要结束了,而我由于要出国的原因在寒假中参加了新东方TOEFL的培训班,ACM又被我“正当理由”放到了一边,而且整个寒假几乎都没有碰过。而大二下学期开学后我又复习了一段时间TOEFL,直到考试结束我才又开始了切题的生涯,其实从严格意义上来说,知道这个时候我才开始了真正意义上有计划的且比较刻苦的ACM训练,一直训练到了上海邀请赛的时候。这是我参加ACM以来参加的第一次比赛,也是我第一次用自己的眼睛确认了自己和别人之间的差距,这场比赛给我的震撼很大,一样是大学生,一样的年龄,但是差距却如此之大,确实令人深思。知道这个时候我才真正意识到了自己曾经浪费了多少的时间,意识到了自己的水平竟然和别人有那么大的差距。这之后不久,大二的暑假集训就开始了,这两个月是我搞ACM以来进步最快的一段时间。下面,我就想把自己的一些做题的经验和感受告诉大家,希望能对大家有帮助。
(2).做题要点
首先,我认为最重要的是独立思考和敢于尝试,所谓的独立思考,就是不要养成做不来就上网搜别人代码的习惯,如果实在做不出来,可以尝试问一下别人思路,然后再尝试自己去实现,等做出来之后再看别人的代码,学习一些好的地方。而所谓的敢于尝试,就是不要怕错,编程是一件很特别的事情,他可以在当场验证你的理论的正确性,所以,不要把错藏在心里,打开电脑自己试下,自然就明了了,也只有这样,你才能从自己完成的每一道题中获得快乐。其次,就是要写解题报告,把自己在这道题中学到的知识和碰到的问题记下来,并经常梳理总结自己学过的知识,把他们联系在一起。当你坚持到这里的时候,我想和你说,你是好样的,但是,还请你继续坚持下去,因为ACM中真正的乐趣才刚刚开始,也就是算法。我想,包括我在内的大部分初识算法的同学都会感到非常的迷茫,因为就我的经历来说,我是几乎每拿到一道题,大部分情况下都是一点没有思路,难得有点思,写了老半天还可能是错的,我想,碰到这种情况的你完全不必担心,因为有相当一批人都是和你一样的,同时,在他们当中也有很多的人成为了相当出色的ACMer,当然了,这也是在他们付出了相当的努力之后才取得的结果。所以,我相信,只要你坚持下去,终会有收获的一天的。那么在下面一大点中,我想说下你们要攻克的几个最主要的方面。
(3).动态规划(Dynamic Programming,以下简称DP)
俗话说,要看一个人的算法水平,只要看一下他做DP题的水平就OK了,而在ACM这个多变的赛场上,几多算法沉浮,唯有DP几乎从未消失过,如果你问我什么类型的题在赛场上出现的概率最高,我可以毫不犹豫地告诉你,是DP。由此也可以看出,DP的地位有多么重要,那么这样一个几乎每场比赛都会出现的题型,应该很难啊,为什么要让我们从DP入手呢?确实,DP是很难,其变型之多,覆盖知识面之广,确实让人望而生却,但是,我想说下如何入门DP题。首先是DP几个最为基本的模型,LCS(最长公共子序列),LIS(最长上升子序列),最大公共子段和,数塔问题,矩阵连乘等几个最为经典的问题,大家一开始的时候可能难以理解DP中自底向上,重叠子结构等基本思想,对于这几道问题可以先看一下别人的代码和书上的讲解,然后再自己反复地理解,理解了之后再自己敲一下代码,如果有地方实在不理解,可以先放一下,去看看其他题,回过头来再想一下以前的题,也许会有豁然开朗的效果。吃透了DP的几个经典问题之后,就可以做一下这些经典问题的变型了,比如最大公共子段和的变型——最大子矩阵和最大m子段和,最长公共子序列和最长上升子序列的变型——最长公共上升子序列等等。并且可以尝试接触DP的一些重要的应用,最重要的要数背包问题,背包问题是DP一个很大的分支(算是分支吧,我找不到其他更好的词来形容他了),同样也有非常多的变型,如最为基础的01背包,以及扩充出去的多重背包,完全背包,分组背包,树型DP(这个知识点我待会会介绍)中应用非常多的泛化背包等等,下面我把最为基本01背包,多重背包和完全背包讲一下,首先是最简单的01背包,伪代码如下:
for i=1..N
for v=V..0
f[v]=max{ f[v], f[ v-c[i] ] + w[i] }
这里为什么要倒推呢?其实道理很简单,因为这里其实是利用类似滚动数组的概念,只不过他连2个数组都不用开了,只需要开一个数组就可以了,这是为什么呢?因为传统的二维数组中f[i][v]的值是由max( f[i-1][v], f[i-1][ v-c[i] ] + w[i] )得来的,所以每次f[v]的值是由上层循环中f[v'](v’<=v)得来的,所以改成了一维数组后,如果从小到大循环的话,在计算完成f[v] 之后,就会在计算f[v'](v' >=v)时发生错误,因为原本计算f[v']所需的上层循环中的f[v]的值已经被新的值覆盖掉了,故必须从大到小循环。其次是多重背包,完全可以化为01背包问题,不过是把相同价值的同种类物品看成多个价值相同的不同种类物品,即比01背包多了一重循环,要注意的是这两层循环都要从大到小,原理和01背包类似。最后是完全背包问题,伪代码如下:
for i=1..N
for v=0..V
f[v]=max{ f[v], f[ v-c[i] ] + w[i] }
这个伪代码与01背包的伪代码只有v的循环次序不同而已。为什么这样一改就可行呢?首先想想为什么01背包中要按照v=V..0的逆序来循环。这是因为要保证第i次循环中的状态f[i][v]是由状态f[i-1][v-c[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果f[i-1][v-c[i]]。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑 “加选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果f[i][v-c[i]],所以就可以并且必须采用v= 0..V的顺序循环。这就是这个简单的程序为何成立的道理。这里我向大家推荐一下浙江大学的DD牛所写的《背包九讲》,是背包入门及提高的最为经典的资料。现在就要讲一下树型DP了,树型DP其实就是DP,只不过是建立在树模型之上的DP罢了,不过树型DP说起来虽然简单,确是DP中相当困难的一个知识点,要好好理解,多做些题才行。最后是状态压缩DP,这也是一个DP的一个难点,所谓的状态是指利用二进制或者其他进制的数来表示状态从而达到空间上压缩的目的,这一类的状态设计一般都很巧妙,而且涉及的众多位运算对于编码能力也是一个相当大的挑战,介于状态压缩DP是用记忆化搜索(所谓记忆化搜索,是DP的另外一种递归的实现形式,即所谓的自顶向下)来实现的,又要牵涉到搜索的知识点,建议等学习了相关的内容之后再回过来头来学习这个知识点。状态压缩的经典题有棋盘覆盖问题,炮兵阵地等。
(4).搜索(包括DFS, BFS, A*)
搜索也是ACM中相当重要的一个组成部分,涉及范围也是相当之广,首先是最为基础的深度优先搜索DFS,所谓的DFS,其实就是通过递归的方式枚举所有的可能从而得到我们想要的结果,而搜索中相当重要的一个技巧就是剪枝,即人为地删去一些没有必要搜索的可能,从而提高我们程序的效率,DFS的经典题有最为著名的八皇后为题,Sticks等等。其实DFS的题实在是太多了,PKU上有很多的题可以供我们练手。另外一个就是广度优先搜索(BFS)了,广度优先搜索是基本思想就是建立一个队列(队列是一种基本的数据结构,我会在下一部分中说明),然后每一次都拿出队列出的一个点扩展出所有的可能,再把我们需要的解放入队列等着下次再扩展,一直扩展到找到答案或者不能扩展为止,BFS的经典题有跳马问题,八数码等。BFS有一个非常常用的技巧或者说是优化,就是双向BFS,思想是一样的,就是同时从起点和终点开始扩展,等到出现交点的时候就意味着找到了答案,这样比起普通的BFS可以节省大量的空间和时间。BFS还有另外一个常见的扩展,就是优先队列BFS,所谓的优先队列(优先队列是队列的一种实现,我同样会在下一部分中给出解释),就是始终都保持队列的队首元素是最小的,这样每次扩展的就是当前最小的元素了,这里所谓的最小,其实指的是当前看来最优的解,利用这么一种贪心的方法,来加快我们搜到答案的速度,当然了,具体的效率还要看题目的数据。说了这么多的优先队BFS,其实就是A*的一种特殊情况,A*的中文名是启发式搜索,是人工智能中常用的一种搜索技术,A*最基础的应用就是求最短路,通过某个估价函数对当前的点做出一个价值评估,然后将这些扩展出来的点按照其价值放入一个优先队列中,那么我们每次拿出的队首元素不就是我们当前最优希望的一个点吗?如果用STL(C++的标准模板库,同样会在下一部分中给出解释)来实现优先队列的话,A*比起BFS的代码量几乎没增加,无非多了一个估价函数,但是问题就在于如何更好地设计出一个估价函数,A*的经典题有贪吃蛇,八数码。
(5).C++应用之STL
STL是C++的标准模板库,为我们提供了相当多的现成的库函数和数据结构,STL即可以极大地缩短我们的代码长度,有可以极大地降低我们出错的概率。那么你可能就奇怪了,为什么我还会恨STL呢?理由很简单,我们必须要付出相当大代价,那就是效率。下面我简要地介绍一下STL在ACM中的简单应用。首先就是STL中的库函数了,其中我们有我们最为常用的sort排序函数,有find,lower_bound和upper_bound等一些查找函数用来简化我们的代码,另外最常用的就是顺序容器和关联容器了,其实顺序容器可以相当相当程度上代替一些常用的基础数据结构如vector可以代替长度可变的数组(可以简单地实现邻接表),list可以代替链表,stack可以代替栈,deque可以代替双端队列,priority_queue可以代替我们前面提到的优先队列,而关联容器中的map可以实现任意两种类型的数据之间的索引,而set可以查找某个集合中是否存在某个元素。
(6).数据结构之基础篇
数据结构在ACM中应用非常广泛,但单独考查某个数据结构基础的题目较少,一般都是起一些辅助的作用,比如我们前面提到的优先队列,还有一个非常常用的就是哈希,前面我们提到过,在BFS的过程中,我们需要从每次扩展出来的点中筛选出来我们需要的点放入队列中,哪些是不需要的点,一般来说,指的是和以前某个搜索过的点具有相同的状态的点,而判别状态是否相同的方法经常是利用哈希来保存以前搜索过的状态,并且判断每次扩展出来的点的状态是否已经存在,如果不存在,我们再把它放入队列中。
(7).数据结构之提高篇(包括并查集,树状数组,线段树)
数据结构还有一些相对来说比较高级的应用,这些知识点可能会作为一个知识点单独考查,首先是并查集,并查集的基本思想是在一个集合中选出一个元素作为一个集合代表元素,通过对这些代表元素的操作来实现集合之间的合并操作,在求最小生成树的经典算法Kruscal中也会用到并查集来判断两个元素是否属于同一条边,这在下一部分图论的总结中会提到。并查集也有很多的题目可以做,经典题有食物链,搞同性恋的虫子,帮派结伙等,并查集还有一个变型就是从一个集合中删除某一个元素,我们知道,普通的并查集是不包括删除元素的功能的,而删除元素的实现其实也很简单,就是对每个点都建立一个索引,一开始每个点的索引都指向自己,当一个元素被删除掉的时候,先建立一个新的不属于任何集合的新点,在把被删除掉的点索引到那个新点之上即可。第二部分是树状数组,他支持在O(nlogn)的复杂度内算出区间内的元素之和,他的思想很是巧妙,就是树状数组总结:假设c[]为树状数组,a[]为原数组,则两者之间存在这么一个关系,c[i]代表的意义是从a[i]开始往前2^k个元素的和(k为i化成二进制后尾部包含的0的个数)。由位运算的性质可以得到:对于i来说,i&(-i) = 2^k,那么树状数组的基本功能就能明白了。树状数组也有比较多的题,PKU_1990,PKU_2828,PKU_2155等等。最后我想说一下线段树,线段树是一个非常强大的数据结构,他支持在O(nlogn)的复杂度内对区间范围内的元素进行修改,删除等操作,和并查集,树状数组不同的是,线段树的实现非常灵活,一道题一个样,几乎没有什么定式,当然了,最为基本的思想就是每个节点代表一个区间,他的左右子树分别为左半区间和右半区间,如此这般地递归定义,直到为一个点或者包含一个单位为止。线段树也有几个基本模型和经典例题,首先我想讲的是线段树的一种相对而言比较简单但是非常经典的应用来引入线段树的概念等一系列问题,即染色问题。以PKU_2777为例,题目大意是有一块长度为L厘米的板,每厘米可以看成一个单位区间,颜色的种类由数字表示,一开始每个区间的颜色都是1,而现在要对这种树进行O次操作,操作有两种,第一种是将区间A到B的颜色都染成C颜色,第二种是询问区间A到B之间一共有几种颜色。最简单的想法可能是开个长度为L的数组a[],而a[i]存的就是第i格的颜色,但是可行吗?我们来看下数据范围,L的范围是十万,O的范围也是十万,那么算法复杂度就是O(LO),明显会超时,所以我们选择用线段树来帮助我们解决这个问题,其实线段树这个名词并不能很好地阐述它强大的功能,我更喜欢他的学术名词——区间树,同其他的树一样,他可以在O(logL)的时间内对树进行维护操作,这也就意味着他可以在O(logL)的时间内对一段区间进行一系列的操作。正是线段树这种高效,让他在RMQ问题,以及求矩形合并面积,周长等一系列问题中得到了充分的应用。这里我想讲一下RMQ问题(即求区间内最大or最下值的问题),众所周知,RMQ问题有一种O(NlogN)的预处理以及O(1)时间求出任意区间内最大or最小值的离线算法(所谓离线,即不能在求的过程中动态地改变或者插入区间的值),即ST(Sparse Table)算法,相比之下,线段树并没有效率上的优势,但ST算法有个局限性,就是不支持在线操作,而线段树则没有这种限制,由此可知,线段树的强大。线段树还有一个高级的应用就是对于区间最值信息的维护,相应的经典题有很多,也有一定的难度,PKU_2482,PKU_1151(求n个矩形合并后的面积),PKU_1177(求n个矩形合并后的边界周长)等等。最值的维护要抓住的基本思想就是递归地维护每一颗子树,利用子树的信息去维护父亲。
( 8 ).字符串(包括KMP算法,Trie树,后缀数组)
字符串处理也是ACM中相当大的一块知识面,而且也具有相当的实际应用面,其实对于字符串的知识我自己接触的也比较少,所以只能简单地谈一下几个最为基础的算法,KMP算法是两串匹配最为基础的线性算法,该算法的核心是对于next数组的理解,该数组是对于一个串进行了预处理得到的,从而成功将两串匹配的复杂度降到了线性。但KMP算法只能是两串之间的匹配,如果我们要多串匹配的话该怎么办呢?Trie帮助我们解决了这个难题,Trie其实是一颗字母树,树的每个节点都有26个英文字母,通过对这些节点的进行标记来插入一个字符串,在插入了n个字符串之后,我们就可以同时对这n个字符串之后我们就可以同时对这n个字符串进行匹配了,Trie树有一个很大的缺点,就是他所需要的空间是指数级别的,所有一般来说字符串的长度超过15的话我们就应该考虑别的方法了。最后是后缀数组,算法的主要精髓在于对于height数组的理解和应用,在国家队论文中有两篇文章是专门介绍后缀数组的,我这里就不赘述了。
(9).图论(包括最短路,最小生成树,强连通分量等知识点)
之所以把图论的内容放在最后讲完全是因为图论的知识点实在是太多了,涉及的方面也实在是太广了,我这里挑几个我题目做得比较多的方面来简单总结一下。首先就是最短路了,主要分为多源最短路,用的算法是非常经典的Floyd算法,复杂度为O(n^3),实现起来相当简单,主要就是DP的思想。还有单源最短路,最原始的方法是Bellman-Ford,但该算法使用的概率不高,因为他有一个非常好的替代品,就是SPFA,SPFA的实现有点类似广搜,代码也非常短,对于Bellman-Ford求一个图中是否存在负环的问题可以以完全地替代,另外在稀疏图中求最短路的效率甚至要高于用了优先队列优化过的Dijkstra,确实是一个实用的好东西。当然了,最为著名的Dijkstra算法是绝对不能不提的,该算法是我们求最短路时最常用的算法,但是由于他利用了贪心准则,所以一定要注意Dijkstra不能处理有负权边的图,而Dijkstra也有几个非常经典的变型,一个是将Dijkstra扩展到二维来求次短路,另一个是利用A*来求次短路,大家是否注意到,学到后面的时候,不同知识块之间的分界已经越来越不清晰了,因为我们的脑中正在逐步形成一张知识网,将每一个知识点都有机地串联到了一起。第二个大点是最小生成树,该类问题也有两个广为人知的算法,适用于稠密图的Prim算法和适用于稀疏图的Kruscal算法(该算法中要用到并查集的算法),最小生成树同样有一些经典的变型,如次小生成树,最小限制度生成树等。再有一类非常重要的问题就是二分匹配问题,该问题涉及的知识点也是相当的多,求无权二分图的最大匹配有最为经典的匈牙利算法以及求带权的二分图的最小/大匹配的KM算法,而由不带权的二分图的最大匹配数衍生出去的知识点有最小覆盖问题,最小路径覆盖问题等等,这块内容概念性比较强,其实说起来,图论最大的特点就是概念性强,变型极多,如果不深入地理解每一次问题及其经典算法,着实无法应变。最来就是欧拉回路问题,该类问题比较简单,无非就是两种,一个是判断图中是否存在欧拉回路,再一个就是求出该欧拉回路中的一条,实现起来也很简单,一个DFS就可以完成了。还有就是强连通分量,该算法的核心就是对一个图求强连通分量后缩点,从而将一个图转化成一个有向无环图,从而方便我们进行接下去的操作。最后一个大块就是网络流了,该内容我涉及也比较少,主要就是几个求网络流的经典算法,如HLPP,ISAP,EK等等,网络流的变型也非常地多,需强加练习。
(10).ACM最终回之比赛篇
我参加过的正式比赛有两场,一场是大二下的上海邀请赛,另一场是大三上的合肥区域赛,由于水平有限,终究还是只拿了两块铜牌,下面我想谈一下组队的个人感受,首先是队员的构成,至少要有一个编码能力较强的人主要负责敲代码以增加出简单题的速度,另外就是数学基础较强的人,由于ACM现在越来越喜欢出数学题,而且数学好的往往思路会比较开阔,可以为整个团队提供想法,再有一个就是算法接触比较广的人,这一类的人接触的算法比较多,切题数也比较多,虽然可能没有哪个方面特别强,但其丰富的做题经验保证了他对于一道题的算法嗅觉,可以为整个团队指明方向。说到这里,我的总结也要结束了,希望大家可以从中可以获得帮助。