ACM 学习心得
STL:完美的艺术品
STL 由四大部分组成:算法、容器、迭代器、仿函数。
算法(algorithm)
算法定义了一组与实现无关的操作,也是 ACM 学习的核心。C++ 算法库的内容全都是一些比较基本的算法,包括移动、转换、遍历、删除、过滤等等。C++ 算法库本身是基于抽象的,在迭代器的抽象下,使得这些算法可以在不同结构的容器中重用。一个比较坑的地方就是我高中的时候学完 C++ 之后报名了 NOIP。那一年刚刚允许用 STL(之前一直不准用),然后我对于标准库的依赖很严重,连快排都不会写,后来才慢慢的把基础补上去。
容器(Container)
容器库中包含了大量的基本数据结构与基本对象:数组、位容器、链表、双端队列、向量、集合、映射、元组、优先队列以及两种适配器。这些基本的数据结构可以在编程过程中大大简化程序员的工作,同时这些容器使用起来十分的方便,也可以与算法库无缝的结合。
迭代器(iterator)
标准模版库中有一层很重要的抽象:迭代器。迭代器通过大量的运算符重载使得本身被适配成指针的模式。方便便利与操作容器的内部,同时保证容器的一致性。
仿函数(functor)
最后一组是仿函数,也是 STL 中最容易被忽视的一组。greater、less、equal_to、plus等都是仿函数。仿函数也被称为函子,这一类对象有一个极为特殊的特性,重载了 operator() 运算符。这一技术使得对象变的可调用,表现的就像函数一样,但与函数不同的就是它的内部可以保存状态。是高阶函数与函数柯里化的一个简单的实现。这种实现可以让「函数」变成一等对象,在程序中创建、传递。经常被于谓词之类的方面,以提高程序的可读性与美观性。但是 ACM 中很少使用。
贪心算法:往往没有那么好贪
一开始感觉贪心算法是最无脑的算法了,很简单也很好理解,排个序然后排头找就可以了。后来做一些题,觉得贪心要是难起来也是相当恶心。
什么时候用贪心呢:最优子结构。能用贪心的情况下,局部最优解一定是包含在最优解里的,这样每一步都可以在找到局部最优解之后「不负责任的」抛弃其他情况。由于这个特点,所以贪心不能回溯,不能记忆。问题的关键是有很多问题是没有办法一眼瞅出来到底是贪心还是动态规划,这就需要慢慢的证明了。
实际上即便是确定了用动态规划去解,贪心也可以做一个简单的估计,来初步的拟合结果。当时刚刚学 DP 的时候,做一道题,是有关于分书的,然后我就是用的一种修正过的贪心,发现除去一些特别的数据,结果和 DP 是一样的。如果选取合适的策略,贪心可以为最优解拟合出一个很满意的边界。当然在 ACM 中没有这么干的,但是在实际有用的应用,能确定一个大致的边界有时就已经够了。中比赛骗分最常见的手法就是贪心骗 DP。不过往往要想一个比较高级的贪心。
很多经典问题都是贪心思想:霍夫曼树、最小生成树、最短路等。但是我个人感觉其实贪心并没有那么简单,RQNOJ 上面有很多特别恶心的贪心,有些的思想难度不亚于 DP,要「小心的贪,谨慎的贪」。
贪心有这几个重要的部分:候选集、解集、贪心策略、可行函数、解决函数。伪代码如下:
while(解决函数 == false && 候选集.hasElement) {
var 元素 = 候选集.getOne()
if (元素可行) {
加入解集
}
}
搜索:这个也能搜,那个也能搜,统统都能搜
当时我们那届,省队有一个「搜索帝」。特别厉害,遇到不会做的题就用搜索,然后一路从 NOIP 搜到了 NOI…… 然后搜索和 DP 这两个在数学领域完全不沾边的概念,在计算机里却非常相似,DP 有的时候很像记忆化搜索。所以没掌握好搜索的我连带着 DP 一块跪了_(:3」∠)_
我搜索一直就迷迷糊糊的,只明白了图上的搜索,隐式图的搜索就不行了。
广度优先搜索(BFS)
广度优先搜索还是比较好理解的。但缺点是会爆堆内存。
首先队列中有一个顶点。顶点出队,然后相邻的未访问的顶点入队。检查是否存在可行解,如果没有则重复直到队列为空。BFS 用类似于扩散的方式来实现点的寻找,在解树上的表现是每次扩展都有下一层的节点被访问。
伪代码:
队列 Q 加入顶点
while (Q 不为空) {
var 顶点 = Q.pop_front()
for(顶点相邻的未访问顶点) {
if (是目标顶点) {
break
}
标记为访问
入队
}
}
深度优先搜索(DFS)
深度优先搜索就有点麻烦,主要的原因是大部分实现是递归的实现,一开始接触的时候很容易晕。DFS的一个坑就是会爆栈。所以有时候会手动模拟。首先访问顶点,把一个与顶点相邻的未访问的顶点作为顶点去访问,重复直到找到可行解或者全部被访问。在解树上的表现是每次都是一下子走到底再回头。
伪代码:
访问顶点
标记访问
for(顶点相邻的未访问顶点) {
重复
}
深度优先搜索的内存增长很慢,每次都只处理一个节点,但是简单的递归写法存在调用栈溢出的风险,在高压力的情况下要手动模拟。而广度优先搜索内存增长非常快,如果情况比较密集,就很容易溢出。
深度优先搜索往往用来确认是否存在最优解,广度优先搜索更适合于寻找最优解。
除此之外还有一些变体,比如:迭代深化深度优先搜索(IDDFS)等价于广度优先,但是逐步放松深度限制。这种变体使得内存消耗较为缓慢,在相邻状态极为庞大而最优解相对简单的情况先非常好用。当目标解已经确定的,希望找到路线的时候,双向广度优先搜索就非常棒了,可以很好的减轻内存压力。思路是从起点与终点同时进行广度优先搜索,发现公共点就停止。迭代深化深度优先搜索在 ACM 中应用较少,因为很少遇到这么大的解集。
动态规划:明白是明白,但就是不会写
继搜索之后,第二个头大的就是动态规划。主要是这个东西最奇怪的一点就是:这个题我不会做,看了答案之后知道这么做对,但是把答案拿走之后又不会了…… 不过在老师刷题的时候手下留情多了。很多题目都是比较简单的递推基本上方程一眼就能看出来。高中学的时候老师说这个是最难理解的,考试的时候也特别能拉开分数。必须要刷很多的题目才能明白,我就属于当时偷工减料的那种……
动态规划有一个特点是局部最优解不一定是整体最优解,这是与贪心算法很大的区别。原来局部的最优解可能在以后的决策当中被舍弃。很多问题动态规划看起来都能用贪心去解,但是数据一变就会发现贪心算法不行。感觉这的地方还是要看人,像我这样的基本上看到一个动态规划都感觉是贪心算法,然后一会写完结果 WA。有些细心的人会先想一想然后再做。
动态规划应该是算法比赛里面最神秘的一个领域了,会的人感觉很简单,随便做一个题一眼就能写出公式,不会的怎么看都看不懂。网上有很多大神写了很好的文章,有的人在理解的时候把 DP 抽象成状态机,但是我感觉这样思想更难理解,因为状态的转移的动机变得很奇怪。原来状态转移是由环境决定的,而现在的状态转移是由节点本身的性质与策略所推动的。
动态规划最难的地方是状态的定义(个人感觉),当时奥赛组的大神说过,只要定义好状态,其他的就没有什么难度。定义状态的过程其实就是在拆分问题。问题拆分好了之后状态方程就水到渠成了(但是我往往连状态都定义不出来……)。往往一开始就迷失在如何定义状态了(老师OS:就是做题做少了)。
更加「迷人」的一个东西就是如同玄学一般的「无后效性」。恶心的是对于同样的一个问题,不同的状态的定义有写是由后效性的,有些是无后效性的。所以如果不定义好状态的话,往后会非常困难。
动态规划有这几个概念:状态决策,阶段,状态转移(通过这些名词我们可以进一步把动态规划复杂化……)
数字三角形是我第一个接触的动态规划,个人感觉这个比背包要好理解,而且可以把图画出来,状态也很好确定。整个题下来一直是很直观的。
01背包是一般动态规划上来就会讲到的例子,背包一族的题目都是围绕着这个类型展开的。然后是就算动态规划全部忘了也不会忘记的方程:
f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}
01背包讲完之后会使用滚动数组的技术来压缩空间,注意 V 是逆序枚举的。
f[v]=max{f[v],f[v-c[i]]+w[i]}
最长不下降子序列是一个一维状态的动态规划,看懂背包之后做这个题又懵了:「这也是动态规划」?
d[i] = max{1, d[j] + 1}
最后一部分是到底什么时候用搜索,什么时候用动态规划,这里直接贴一段我从网上看到的:首先,可以从数据范围中得到命题人意图的线索。如果一个背包问题可以用动态规划解,V一定不能很大,否则 O(VN) 的算法无法承受,而一般的搜索解法都是仅与 N 有关,与 V 无关的。所以,V 很大时(例如上百万),命题人的意图就应该是考察搜索。另一方面,N 较大时(例如上百),命题人的意图就很有可能是考察动态规划了。
另外,当想不出合适的动态规划算法时,就只能用搜索了。例如看到一个从未见过的背包中物品的限制条件,无法想出动态规划的方程,只好写搜索以谋求一定的分数了。
图论:最有趣也是最杂的
图论是我最喜欢的部分,因为这个能画出来 (′?‵)。图论是离散数学的范畴,但是高中刚刚接触的时候并不知道有离散数学这一门课,单纯感觉很有趣:那时候的数学都是坐标系,几何体之类的,而图论本身对于空间没有任何要求。再就是相比较其他数学分支,图论用计算机语言太好描述了。
图论比起其他的部分显得很杂,有很多方面要去学。遍历、拓扑排序、最短路,二分图。在往上就是网络流之类的。图论的算法特别杂而且每一个都要好几种……
最小生成树:最小生成树具有 MST 性质,这一点使得最小生成树可以用贪心去解。最小生成树有两种解法,第一种是 Prim、第二种是 Kruskal 算法。
Prim
按照边去找点,算法的复杂度仅仅与边有关,适合稠密图,复杂度O(v^2)
1. 初始化
2. 输出顶点 V0,将顶点 0 加入集合 U 中
3. 重复以下操作
3.1 选取最短边与对应的邻接点编号
3.2 输出顶点的编号与对应的权值
3.3 顶点 k 加入 集合 U
3.4 松弛
Kruskal
按照边去贪心,注意每次加入的边不能出现环,适合稀疏图,复杂度 O(e lg e)。
1. 初始化 U = T; TE = {}
2. 重复直到 T 的联通分量为 1
2.1 在 E 中寻找最短边 (u, v)
2.2 如果 (u, v) 分别位于两个不同的联通分量
2.2.1 (u, v) 加入 TE
2.2.2 合并连个联通分量
2.3 E 中标记 (u, v) 无效
这个图是我模拟出来的,上面的是保持联通的边界,在边界之上图就回出现多个联通分量,斜线的斜率越低说明 V/E 越高,也就意味着图越密集。而最下面那条的曲线之下是 Prim 算法的可行区域。
我初步拟合了一下,在 V < 20 以内时候, V/E < 0.2 的时候就才能用 Prim;在 V < 100 的时候 V/ E < 0.0075 才能用 Prim。也就是说,所谓的 「稀疏图」的范围其实并没有那么「稀疏」,大部分情况下都可以用 Kruskal,只有当边数是点数的几十倍以上的时候再考虑 Prim。(更关键的是 Prim 更难写)
最短路也是两种比较常用:Dijkstra 和 Floyd 分别对应的单源最短路和图上所有的最短路。
Dijkstra
核心很简单,就是一个松弛操作:
dist[i] = min {dist[i], dist[k] + G[k][i]}
1. 初始化数组 dist, path, s;
2. 当 s < n 时,重复
2.1 dist[n] 最小值的编号为 k
2.2 k 加入 s
2.3 松弛 dist
Dijkstra 可以使用优先队列优化。
Floyd
更简单,直接一个三重循环内部松弛就出来了。
可以看出来松弛在图论里是经常使用的一种模式,