还记得是在王鲁老师的学习交流群了看到他帮费老师发的广告才关注到这门课,后来看到学分3.5,这诱惑真心大~然后想也没想就选上了。当我意识到我的选择意味着什么的时候费老师又给了我一次放弃的机会,然而我最后选择了坚持下去,挑战自己!一学期下来,感觉收获是很大很大的,不仅局限在知识方面很多方面都得到了锻炼。所以也是真心的觉得这课选的很值。废话不多说~下面来总结下这学期学习的内容。
第一专题:贪心算法
这一专题的题目做了不少,但似乎对贪心的感念依然有些模糊,下面简单写下我的理解。
一.概述
所谓贪心算法,就是用将一个大的问题细化成若干小问题,通过逐一解决这些小问题,最终求得问题解的方法。这种策略往往易于描述,易于实现—策略可行的话。
二.例题举例
1.木棒问题。
题意:一个加工木棍的机器,如果后面加工的木棍比前面的长且重,则不需要调机器,否则需要一分钟调机器,输入T组测试实例,每组由N跟木棒编写程序,计算并输出每组测试实例所用的最短的调机器的时间。
这个问题首先要对每组测试实例里的木棒进行排序,按长度升序,长度一样的轻的在前—预处理。
接着来到整个算法的核心部分—贪心策略:将木棒进行遍历,每一遍删去所有可以一起加工不用调机器的木棒,调机器用时+1,直至所有木棒都删完。
2.田忌赛马问题。
题意:输入田忌的马分数(分数代表好坏),国王马的分数,输出田忌最多能赢几局。
预处理依然是排序。
贪心策略:如果田忌最好的马能赢国王最好的马,让他俩比一局;如果田忌最差的马能赢国王最差的马让他俩比一局;如果上面两个都不行让田忌当前最差的马与国王最好的马比一局。
3.搬桌子问题。
题意:在400个两两相对房间之间搬桌子,走廊一次只能通过一张桌子,把桌子从一个房间移到另一个房间需要10分钟。输入T表示搬桌子的组数,输入N表示每一组要搬的桌子数,接下来的N行输入桌子搬出的房间和搬入的房间。输出每一组搬桌子的最短时间。
预处理排序。
贪心策略:因为走廊不可以同时搬运两张桌子,可以将每两个相对的门之间的走廊设为一个参数,统计每组桌子搬完后走廊的占用次数,最大占用次数乘10即为所求时间。
三.总结
从上面几个例题来看,要做贪心,往往要对问题进行一定的预处理,往往是排序(并不一定是)。然后进入到整个问题的核心部分贪心策略,贪心策略有的时候是实现问题的转化(如例3),有时候不用转化而是反复遍历。
贪心算反往往配合STL内容来实现,常用有:Vector,Set;sort排序函数也是常用的。
第二专题:搜索
提起搜索,大家都不会陌生。它的应用是十分广泛的,比如目前internet上的搜索引擎,WINDOWS XP操作系统中的文件搜索。同时,搜索是编程解题的一种重要的手段,在竞赛中,我们有时会碰到一些题目,它们既不能通过建立数学模型解决,又没有现成算法 可以套用,或者非遍历所有状况才可以得出正确结果。这时,我们就必须采用搜索算法来解决问题。几乎每次ACM竞赛都要考察到这方面的内容。因此,如何更深
入地了解搜索,从而更为有效地运用这个解题的有力武器,是一个值得深入研究的问题。要掌握搜索的应用技巧,就要了解它的分类及其各方面的特点。
第一部分 基本的搜索算法
一、回溯算法
回溯算法是所有搜索算法中最为基本的一种算法,其采用了一种“走不通就掉头”思想作为其控制结构,其相当于采用了先根遍历的方法来构造解答树,可用于找解或所有解以及最优解。
评价:回溯算法对空间的消耗较少,当其与分枝定界法一起使用时,对于所求解在解答树中层较深的问题 有较好的效果。但应避免在后继节点可能与前继节点相同的问题中使用,以免产生循环。
二、深度搜索与广度搜索
深度搜索与广度搜索的控制结构和产生系统很相似,唯一的区别在于对扩展节点选取上。由于其保留了所有的前继节点,所以在产生后继节点时可以去掉一部分重复 的节点,从而提高了搜索效率。这两种算法每次都扩展一个节点的所有子节点,而不同的是,深度搜索下一次扩展的是本次扩展出来的子节点中的一个,而广度搜索 扩展的则是本次扩展的节点的兄弟节点。在具体实现上为了提高效率,所以采用了不同的数据结构.
评价:广度搜索是求解最优解的一种较好的方法,在后面将会对其进行进一步的优化。而深度搜索多用于只要求解,并且解答树中的重复节点较多并且重复较难判断时使用,但往往可以用A*或回溯算法代替。
第二部分 搜索算法的优化(一)
一、双向广度搜索
广度搜索虽然可以得到最优解,但是其空间消耗增长太快。但如果从正反两个方向进行广度搜索,理想情况下可以减少二分之一的搜索量,从而提高搜索速度。
二、分支定界
分支定界实际上是A*算法的一种雏形,其对于每个扩展出来的节点给出一个预期值,如果这个预期值不如当前已经搜索出来的结果好的话,则将这个节点(包括其子节点)从解答树中删去,从而达到加快搜索速度的目的。
三、A*算法
A*算法中更一般的引入了一个估价函数f,其定义为f=g+h。其中g为到达当前节点的耗费,而h表示对从当前节点到达目标节点的耗费的估计。其必须满足两个条件:
1. h必须小于等于实际的从当前节点到达目标节点的最小耗费h*。
2. f必须保持单调递增。
A*算法的控制结构与广度搜索的十分类似,只是每次扩展的都是当前待扩展节点中f值最小的一个,如果扩展出来的节点与已扩展的节点重复,则删去这个节点。如果与待扩展节点重复,如果这个节点的估价函数值较小,则用其代替原待扩展节点。
对A*算法的改进--分阶段A*. 当A*算法出现数据溢出时,从待扩展节点中取出若干个估价函数值较小的节点,然后放弃其余的待扩展节点,从而可以使搜索进一步的进行下去。
四、A*算法与回溯的结合(IDA*)
这是A*算法的一个变形,很好综合了A*算法的人工智能性和回溯法对空间的消耗较少的优点,在一些规模很大的搜索问题中会起意想不到的效果。它的具体名称
是 Iterative Deepening A*, 1985年由Korf提出。该算法的最初目的是为了利用深度搜索的优势解决广度A*的空间问题,其代价是会产生重复搜索。归纳一下,IDA*的基本思路 是:首先将初始状态结点的H值设为阈值maxH,然后进行深度优先搜索,搜索过程中忽略所有H值大于maxH的结点;如果没有找到解,则加大阈值 maxH,再重复上述搜索,直到找到一个解。在保证H值的计算满足A*算法的要求下,可以证明找到的这个解一定是最优解。在程序实现上,IDA* 要比 A* 方便,因为不需要保存结点,不需要判重复,也不需要根据
H值对结点排序,占用空间小。
下面,以一个具体的实例来分析比较上述几种搜索算法的效率等问题。
在scu online judge(http://cs.scu.edu.cn/acm)上有这么一道题目:这就是古老而又经典的15数码难题:在4*4的棋盘上,摆有15个棋 子,每个棋子分别标有1-15的某一个数字。棋盘中有一个空格,空格周围的棋子可以移到空格中。现给出初始状态和目标状态,要求找到一种移动步骤最少的方 法。
看到这个题目,会发觉几乎每个搜索算法都可以解这个问题。而事实确实如此。
首先考虑深度优先搜索,它会遍历这棵解答树。这棵解答树最多可达16!个节点,深度优先搜索必须全部遍历后,才能从所有解中选出最小的一个做为答案,其代价是非常巨大的。
其次考虑广度深度优先搜索,这不失为一个好办法。因为广度优先搜索的层次遍历解答树的特点,一旦搜索到一个目标节点,那么这时的深度一定是最优解,而不必 象深度优先搜索那样继续搜索目标节点,最后比较才能得出最优解。该搜索方法在这道题目上会遇见致命的问题:广度深度优先搜索是一种盲目的搜索,深度比较大 的测试数据会产生大量的无用的节点,同时消耗很多时间在重复节点的判断上。
为了减少重复的节点,加入人工智能性,马上可以想到用A*算法。经过分析发现,该方法对避免产生大量的无用的节点起到了一定的效果,但是会花97%以上的 时间去判断新产生节点是否与已扩展的和待扩展的节点重复。看来如何提高判重的速度成为该题目的关键。解决这个问题有很多办法,比如引入哈希表,对已扩展的 和待扩展的节点采用哈希表存储,减少判重的代价,或者对已扩展的和待扩展的节点采用桶排序,也可以减少判重的代价。我们现在来尝试一下用
IDA*算法。该算法有个值得注意的地方:对估计函数的选取。如果选用当前状态每个位置上与目标状态每个位置上相同节点的数目加当前状态的深度作为估计函 数,由于当前状态每个位置上与目标状态每个位置上相同节点的数目这个值一般较小,不能明显显示各个状态之间的差别,运行过程中会产生大量的无用的节点,同 样会使效率很低,不能在60s以内完成计算。比较优化的一个办法是选用由于当前状态每个位置上的数字偏离目标节点该数字的位置的距离加当前状态的深度作为 估计函数。这个估计函数的选取没有统一的标准,找到合适的该函数并不容易,但是可以大致按照这个原则:在一定范围内加大各个状态启发函数值的差别。
实践证明,该方法用广泛的通用性,在很多情况下可以替换一般的深度优先搜索和广度优先搜索。
第三部分 搜索算法的优化(二)
该部分将谈到搜索与其他算法的结合。再看scu online judge的一道题目: 给定一个8 * 8的国际象棋棋盘。给出棋盘上任意两个位置的坐标,问马最少几步可以从一个位置跳到另外一个位置。
该题目同样是求最优解,如果用一般的深度优先搜索是很容易超时的。如果用广度优先搜索,会消耗大量的内存,而且效率是很低的。这里,我们将尝试用深度优先搜索加动态规划的算法解决该问题。
将该棋盘做为存储状态的矩阵。每个矩阵元素的值是该位置到初始位置最少需要的步数。初始位置的元素值为0。其他位置的元素初始化为一个很大的正整数。首先 从初始位置开始深度优先搜索,例如某次从(i1,j1)到达位置(i2,j2),如果(i2,j2)处的值大于(i1,j1)的值加1,则(i2,j2) 处的值更新为(i1,j1)+ 1,表示从(i1,j1) 跳到(i2,j2)比从其他地方跳到(i2,j2)更优,不断的进行这个过程,直到不能进行下去位置,那么最后的目标位置的值就是解。这就是一个动态规划
的思想,每个位置的最优解都是由其他能够一次跳到这个位置的位置的值决定的,而且是它们中的最小值。同时,该动态规划又借助深度优先搜索这个工具,完成对 每个位置的值的刷新,可以算是一个比较经典的深度优先搜索和动态规划的结合。该问题还需要注意一个剪枝的问题,从起始位置到目标位置的最大步数是多少?经 过计算,最大值是6。所以一旦某个位置的值是6了,就不必再将它去刷新另外的位置,从而剪去了对很多不必要子树的搜索,大大提高了效率。
第四部分结语
本文的主要的篇幅讲的都是理论,但是根本的目的还是指导实践。搜索,据我认为,是当今ACM竞赛中最常规、也最能体现解题者水平的一类解题方法。 “纸上得来终觉浅,绝知此事要躬行。”要想真正领悟、理解各种搜索的思想,掌握搜索的解题技巧,还需要在实践中不断地挖掘、探索。实践得多了,也就能体会 到渐入佳境之妙了。算法的优化是无穷尽的。
第三专题:动态规划
一.概述
动态规划的基本思想:若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
三大重要性质:
最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
子问题重叠性质:子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
无后效性:将各阶段按照一定的次序排列好之后,对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的决策,而只能通过当前的这个状态。换句话说,每个状态都是过去历史的一个完整总结。这就是无后向性,又称为无后效性。
二.分类解析
1.最长递增子序列.
例题:
题意:给出序列a [1], a [2], a [3] ...... a [n],计算子序列的最大总和。
思路:最大子序列是要找出由数组成的一维数组中和最大的连续子序列。方法是:只要前i项和还没有小于0子序列就一直往后扩展,否则丢弃之前的子序列开始新的子序列,同时记录各个子序列的和,最后取他们中的最大值。
2.最长公共子序列.
例题:
题意:求两个字符串的最长公共子序列。
思路:动态的方程在第一个元素的相等的时,dp[0][0] = dp[-1][-1] + 1, 天哪,这肯定就会出错了。在处理时可以选择字符的读取从第一个位置开始,或者把
i 号字符的状态存储到i+1号位置去,这样就从1号开始处理了,判定是就是 s1[i-1] == s1[j-1] ?
3.背包问题.
<1>01背包.
例题:
题意:一个人收集骨头。给出他的背包容量和可选的骨头的体积和价值,输出他的背包能装下的骨头的最大价值。
思路:01背包问题,DP公式都类似:F[i;v] = maxfF[i-1;v];F[i-1;v-Ci] + Wi,由这个公式做变形就可以。下面再来分析一下这个公式:
每种骨头仅有一件,可以选择放或不放。用子问题定义状态:即F[i;v] 表示前i 件物品恰放入一个容量为v的背包可以获得的最大价值。“将前i
个骨头放入容量为v的背包中”这个子问题,若只考虑第i 个骨头的策略(放或不放),那么就可以转化为一个只和前i-1个骨头相关的问题。如果不放第i 个骨头,那么问题就转化为“前i-1个骨头放入容量为v的背包中”,价值为F[i-1;
v];如果放第i 个骨头,那么问题就转化为“前i-1个骨头放入剩下的容量为v-Ci 的背包中”,此时能获得的最大价值就是F[i-1;v-Ci] 再加上通过放入第i 个骨头获得的价值Wi。
<2>多重背包.(没做出来题,只列下思路。)
题意:有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本算法:这题目和完全背包问题很类似。基本的方程只需将完全背包问题的方程略微一改即可,因为对于第i种物品有n[i]+1种策略:取0件,取1件……取n[i]件。令f[i][v]表示前i种物品恰放入一个容量为v的背包的最大权值,则有状态转移方程:f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k<=n[i]}复杂度是O(V*Σn[i])。
<3>完全背包.(依然是只给出基本思路)
题意:有N种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本思路:这个问题非常类似于01背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、取1件、取2件……等很多种。如果仍然按照解01背包时的思路,令f[i][v]表示前i种物品恰放入一个容量为v的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方程,像这样:
f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k*c[i]<=v}
这跟01背包问题一样有O(VN)个状态需要求解,但求解每个状态的时间已经不是常数了,求解状态f[i][v]的时间是O(v/c[i]),总的复杂度可以认为是O(V*Σ(V/c[i])),是比较大的。
总结解题一般步骤:(1)建立模型,确认状态。(2)找出状态转移方程。(3)找出初始条件。
第四专题:图算法
图的基本知识 :
顶点:图中的数据元素称为顶点.
有向图:有方向的图叫有向图.
无向图:没有方向的图叫无线图.
完全图:有n(n-1)/2条边的无向图称为完全图.
有向完全图:具有n(n-1)条弧的有向图称为有向完全图.
稀疏图:有很少条边或弧的图称为稀疏图,反之称为稠密图.
权:与图的边或弧相关的数叫做权(weight).
1. Relaxation(松弛操作):
procedure relax(u,v,w:integer);//多数情况下不需要单独写成procedure。
begin
if dis[u]+w<dis[v] then
begin
dis[v]:=dis[u]+w;
pre[v]:=u;
end
end;
2. Dijkstra
1) 适用条件&范围:
a) 单源最短路径(从源点s到其它所有顶点v);
b) 有向图&无向图(无向图可以看作(u,v),(v,u)同属于边集E的有向图)
c) 所有边权非负(任取(i,j)∈E都有Wij≥0);
2) 算法描述:
a) 初始化:dis[v]=maxint(v∈V,v≠s); dis[s]=0; pre[s]=s; S={s};
b) For i:=1 to n
1.取V-S中的一顶点u使得dis[u]=min{dis[v]|v∈V-S}
2.S=S+{u}
3.For V-S中每个顶点v do Relax(u,v,Wu,v)
c) 算法结束:dis[i]为s到i的最短距离;pre[i]为i的前驱节点
3) 算法优化:
使用二叉堆(Binary Heap)来实现每步的DeleteMin(ExtractMin,即算法步骤b中第1步)操作,算法复杂度从O(V^2)降到O((V+E)㏒V)。推荐对稀疏图使用。
使用Fibonacci Heap(或其他Decrease操作O(1),DeleteMin操作O(logn)的数据结构)可以将复杂度降到O(E+V㏒V);如果边权值均为不大于C的正整数,则使用Radix Heap可以达到O(E+V㏒C)。但因为它们编程复杂度太高,不推荐在信息学竞赛中使用。
3. Floyd-Warshall
1) 适用范围:
a) APSP(All Pairs Shortest Paths)
b) 稠密图效果最佳
c) 边权可正可负
2) 算法描述:
a) 初始化:dis[u,v]=w[u,v]
b) For k:=1 to n
For i:=1 to n
For j:=1 to n
If dis[i,j]>dis[i,k]+dis[k,j] Then
Dis[I,j]:=dis[I,k]+dis[k,j];
c) 算法结束:dis即为所有点对的最短路径矩阵
3) 算法小结:
此算法简单有效,由于三重循环结构紧凑,对于稠密图,效率要高于执行|V|次Dijkstra算法。时间复杂度O(n^3)。
考虑下列变形:如(I,j)∈E则dis[I,j]初始为1,else初始为0,这样的Floyd算法最后的最短路径矩阵即成为一个判断I,j是否有通路的矩阵。更简单的,我们可以把dis设成boolean类型,则每次可以用“dis[I,j]:=dis[I,j]or(dis[I,k]and dis[k,j])”来代替算法描述中的蓝色部分,可以更直观地得到I,j的连通情况。
与Dijkstra算法类似地,算法中蓝色的部分可以加上对Pre数组的更新,不再赘述。
4. Prim (Dijksta的推广)
1) 适用范围:
a) MST(Minimum Spanning Tree,最小生成树)
b) 无向图(有向图的是最小树形图)
c) 多用于稠密图
2) 算法描述:
a) 初始化:dis[v]=maxint(v∈V,v≠s); dis[s]=0; pre[s]=s; S={s};tot=0
b) For i:=1 to n
1.取顶点v∈V-S使得W(u,v)=min{W(u,v)|u∈S,v∈V-S,(u,v)∈E}
2.S=S+{v};tot=tot+W(u,v);输出边(u,v)
3.For V-S中每个顶点v do Relax(u,v,Wu,v)
c) 算法结束:tot为MST的总权值
注意:这里的Relax不同于求最短路径时的松弛操作。它的代码如下:
procedure relax(u,v,w:integer); //松弛操作
begin
if w<dis[v] then
begin
pre[v]:=u;
dis[v]:=w;
end;
end;
可以看到,虽然不同,却也十分相似。
3) 算法优化:
使用二叉堆(Binary Heap)来实现每步的DeleteMin(ExtractMin)操作
算法复杂度从O(V^2)降到O((V+E)㏒V)。推荐对稀疏图使用。
使用Fibonacci Heap可以将复杂度降到O(E+V㏒V),但因为编程复杂度太高,不推荐在信息学竞赛中使用。
(不要问我为什么和Dijkstra一样……观察我的prim和dijkstra程序,会发现基本上只有relax和输出不一样……)
5. Kruskal
1) 适用范围:
a) MST(Minimum Spanning Tree,最小生成树)
b) 无向图(有向图的是最小树形图)
c) 多用于稀疏图
d) 边已经按权值排好序给出
2) 算法描述:
基本思想:每次选不属于同一连通分量(保证无圈)且边权值最小的2个顶点,将边加入MST,并将所在的2个连通分量合并,直到只剩一个连通分量
3) 算法实现:
a) 将边按非降序排列(Quicksort,O(E㏒E))
b) While 合并次数少于|V|-1
i. 取一条边(u,v)(因为已经排序,所以必为最小)
ii. If u,v不属于同一连通分量 then
1) 合并u,v所在的连通分量
2) 输出边(u,v)
3) 合并次数增1;tot=tot+W(u,v)
c) 算法结束:tot为MST的总权值
4) 分析总结:
检查2个顶点是否在同一连通分量可以使用并查集实现(连通分量看作等价类)。
我们可以看到,算法主要耗时在将边排序上。如果边已经按照权值顺序给出,那太棒了……
另外一种可以想到的实现方法为:O(n)时间关于边权建二叉小根堆;每次挑选符合条件的边时使用堆的DelMin操作。这种方法比用Qsort预排序的方法稍微快一些,编程复杂度基本一样。附程序。
另外,如果边权有一定限制,即<=某常数c,则可以使用线性时间排序以获得更好的时间效率。
最后说说我的感想:ACM全称国际大学生算法设计大赛,是一个高挑战性与高含金量同在的竞赛。通过这一学期的学习,我对这个比赛有了初步的认识,也终于可以理解它为何有如此高的参考价值。至于知识方面,我想我说自己学到了皮毛都有些过,因为涉及的算法知识真的太多了,这真不是一朝一夕就能掌握的,而且学习知识的同时要配合大量的训练。就说现在一天搞不出来一道题,总结来说真不是那块料,仅停留在感兴趣层面吧~