经过十数周的学习,ACM课程已经结束,尽管在学习的过程中遇到了不小的困难,对于很多题目,更是有不少在细心思考后有种无从下手的感觉,但收获还是巨大的!
从贪心算法,到搜索,再到动态规划,以及最后的图算法,每一个专题老师布置的题目,在思考解决之后,刚开始还没有太多的感受,但题目做得多了,自己的思考能力,还是在不知不觉中有所提升。
在这里对于四个专题学习之后,自己做一些总结,毕竟只是一味的学习新知识,而不去复习总结,这样的学习也不会有太大的意义。
第一个专题贪心算法,其主要思想就是,在求最优解问题的过程中,依据某种贪心标准,从问题的初始状态出发,直接去求每一步的最优解,通过若干次的贪心选择,最终得出整个问题的最优解。
贪心算法的理论基础就是在每一步选择中都采取在当前状态下最好或最优的选择,希望得到结果是最好或最优的算法。
贪心算法是一种能够得到某种度量意义下的最优解的分级处理方法,通过一系列的选择得到一个问题的解,而它所做的每一次选择都是当前状态下某种意义的最好选择。即希望通过问题的局部最优解求出整个问题的最优解。
这种策略是一种很简洁的方法,对许多问题它能产生整体最优解,但不能保证总是有效,因为它不是对所有问题都能得到整体最优解。
利用贪心策略解题,需要解决两个问题:
(1)该题是否适合于用贪心策略求解;
(2)如何选择贪心标准,以得到问题的最优/较优解。
贪心算法的求解过程
//A是问题的输入集合即候选集合
Greedy(A)
{
S={ }; //初始解集合为空集
while (not solution(S)) //集合S没有构成问题的一个解
{
x = select(A); //在候选集合A中做贪心选择
if feasible(S, x) //判断集合S中加入x后的解是否可行
S = S+{x};
A = A-{x};
}
return S;
}
(1)候选集合A:为了构造问题的解决方案,有一个候选集合A作为问题的可能解,即问题的最终解均取自于候选集合A。
(2)解集合S:随着贪心选择的进行,解集合S不断扩展,直到构成满足问题的完整解。
(3)解决函数solution:检查解集合S是否构成问题的完整解。
(4)选择函数select:即贪心策略,这是贪心法的关键,它指出哪个候选对象最有希望构成问题的解,选择函数通常和目标函数有关。
(5)可行函数feasible:检查解集合中加入一个候选对象是否可行,即解集合扩展后是否满足约束条件。
对于贪心算法,在课上所讲的几个例题都比较具有典型性,像背包问题、最优装载问题、删除数问题、多次最优服务次序问题、翻转照片问题等,这些题目从根本上都是寻找局部最优,来得到全局的最优,不过针对每一个问题,贪心策略的选择尤为重要。
在做题之中,走廊问题的安排、哈夫曼树等,这些问题在求解中,核心的策略方法对于问题有着至关重要的作用,而学会在思考之中,找到贪心所有贪的关键,无疑对于没有贪心问题都极为重要。
第二个专题搜索,在练习搜索专题的过程中,有关广度优先搜索和深度优先搜索,在解题的过程中遇到了不小的难度,而其中往往是不能够正确区分这两者。
尽管对于一些题目而言,无论使用广度优先搜索,还是深度优先搜索都可以解决,但无疑两者之间,总会有一种比另一种更为优越。还是先来总结一些这两种搜索算法吧!
对于广度优先搜索(BFS)其基本思想是从初始状态S 开始,利用规则,生成所有可能的状态。构成的下一层节点,检查是否出现目标状态G,若未出现,就对该层所有状态节点,分别顺序利用规则。
生成再下一层的所有状态节点,对这一层的所有状态节点检查是否出现G,若未出现,继续按上面思想生成再下一层的所有状态节点,这样一层一层往下展开。直到出现目标状态为止。
广度优先即是要按层数一层一层来遍历,先将一层全部扩展,然后再进行下一层。
这样利用队列先进先出(FIFO)的性质恰好可以来完成这个任务。
具体过程:
1 每次取出队列首元素(初始状态),进行拓展。
2 然后把拓展所得到的可行状态都放到队列里面。
3 将初始状态删除。
4 一直进行以上三步直到队列为空。
深度优先搜索(DFS)的基本思想是从初始状态,利用规则生成搜索树下一层任一个结点,检查是否出现目标状态,若未出现,以此状态利用规则生成再下一层任一个结点,再检查,重复过程一直到叶节点(即不能再生成新状态节点),当它仍不是目标状态时,回溯到上一层结果,取另一可能扩展搜索的分支。采用相同办法一直进行下去,直到找到目标状态为止。深度优先搜索与栈的性质类似。
具体实现过程
1 每次取出栈顶元素,对其进行拓展。
2 若栈顶元素无法继续拓展,则将其从栈中弹出。继续1过程。
3 不断重复直到获得目标状态(取得可行解)或栈为空(无解)。
广度优先搜索框架
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
在搜索专题的练习中,广度优先搜索和深度优先搜索占了很大的比重,而在这里还要提一下二分和三分查找算法,这两个算法理解起来并不困难,直接使用模板就可以。
二分查找算法,在一个单调有序的集合中查找元素,每次将集合分为左右两部分,判断解在哪个部分中并调整集合上下界,重复直到找到目标元素。
时间复杂度:O (logn),优于直接顺序查找O(n)
对于某些问题,如果答案具有特定的范围,并且验证答案是否成立的函数具有单调性。则可以在范围内对答案进行二分验证,从而快速确定答案。
三分法
当需要求某凸性或凹形函数的极值,通过函数本身表达式并不容易求解时,就可以用三分法不断逼近求解。
类似二分的定义Left和Right
mid = (Left + Right) / 2
midmid = (mid + Right) / 2;
如果mid靠近极值点,则Right = midmid;
否则(即midmid靠近极值点),则Left =mid;
第三个专题动态规划,一种解决多阶段决策问题的方法。在做这个专题的练习中,自己的感触还是比较大,其中最为重要的一点就是,对于多阶段的决策问题,一定不要舍弃其前一状态的变化,而孤立的去思考它当前状态会对结果的影响。
在做题的过程中,刚开始的练习,就是不熟悉动态规划的算法、思路,一上来就单纯的孤立考虑当前状态对结果的影响,那么一定不会得到正确的结果。
对于我自身而言,当初在做题的时候,就是因为忽略了前一状态对当前状态的影响,直接孤立的考虑当前状态,致使一些题目,做了N次,最后连题目给出的示例都无法得到结果。
对于动态规划问题的一般解题步骤如下:
1、判断问题是否具有最优子结构性质,若不具备则不能用动态规划。
2、把问题分成若干个子问题(分阶段)。
3、建立状态转移方程(递推公式)。
4、找出边界条件。
5、将已知边界值带入方程。
6、递推求解。
当然任何的算法,最好的解释还是通过题目来说明,在这里再总结一些上课讲的几个典型的例题,算是再度重温动态规划了。
在例题中的最长上升子序列,N层数字三角形最大路径和,滑雪问题,最大子段和问题等,每一例题中,动态规划的使用都是在递归的基础上实现的。
其中像最大子段和问题,其算法的实现主要如下:
状态设计:
dp[i] (1 <= i <= N) 表示以 a[i] 结尾的最大连续子段和。
显然,dp[i] >= 0 (1 <=i <= N)
求解目标:
max{dp[i]} (1 <= i <=N)
状态转移方程:
dp[i] = max{a[i],0} (i = 1)
dp[i] = max{dp[i-1] + a[i],0} (2 <= i <= N)
虽然最大子段和问题,并不能够代表所有的动态规划这一类型的问题,但其解题过程却能够让我们能够清楚的看到,一个动态规划问题,最重要的内容就是其状态转移方程的实现。
只要能够实现状态转移方程,那么代码的实现就非常简单了,而对于状态转移方程,无论是哪一个动态规划的题目,其方法就是递归。
动态规划的其他题目,尽管与最大子段和等并不完全相同,但其核心也都是利用前一状态的结果对当前状态的影响,构造出状态转移方程,得到正确的结果。
第四个专题图算法,这个专题感觉时间有些短,虽然图算法的实现,主要是利用prim,Kruskal等算法,对于最小生成树、最短路径问题,都有着比较固定的模板,但刚刚开始练习,还是感觉有些难理解。
在这里总结一些这几种算法,希望能够加深自己的理解,在这个专题最后的练习中,能够比较深刻的理解算法的精髓,能够灵活的运用。
最小生成树的求解,prim算法基本思想:任取一个顶点加入生成树;
在那些一个端点在生成树里,另一个端点不在生成树里的边中,取权最小的边,将它和另一个端点加进生成树。
重复上一步骤,直到所有的顶点都进入了生成树为止。
Prim算法 :
设G=(V,E)是连通带权图,V={1,2,…,n}。
构造G的最小生成树的Prim算法的基本思想是:首先置S={1},然后,只要S是V的真子集,就作如下的贪心选择:选取满足条件iS,jV-S,且c[i][j]最小的边,将顶点j添加到S中。这个过程一直进行到S=V时为止。
在这个过程中选取到的所有边恰好构成G的一棵最小生成树。
最小生成树的另一种求解算法就是kruskal算法,其基本思想是对所有边从小到大排序;
依次试探将边和它的端点加入生成树,如果加入此边后不产生圈,则将边和它的端点加入生成树;否则,将它删去;
直到生成树中有了n-1条边,即告终止。算法的时间复杂度O(eloge)。
Kruskal算法:
将边按权值从小到大排序后逐个判断,如果当前的边加入以后不会产生环,那么就把当前边作为生成树的一条边。
最终得到的结果就是最小生成树。并查集。
kruskal算法实现的数据结构:
一维数组,将所有边按从小到大的顺序存在数组里面。
并查集。
先把每一个对象看作是一个单元素集合,然后按一定顺序将相关联的元素所在的集合合并。能够完成这种功能的集合就是并查集。
对于并查集来说,每个集合用一棵树表示。
它支持以下操作:
Union (Root1, Root2) //合并两个集合;
Findset(x) //搜索操作(搜索编号为x所在树的根)。
树的每一个结点有一个指向其父结点的指针。
最短路径问题,Dijkstra算法基本思想:设置一个集合S存放已经找到最短路径的顶点,S的初始状态只包含源点v,对vi∈V-S,假设从源点v到vi的有向边为最短路径。以后每求得一条最短路径v, …, vk,就将vk加入集合S中,并将路径v, …, vk , vi与原来的假设相比较,取路径长度较小者为最短路径。重复上述过程,直到集合V中全部顶点加入到集合S中。
Dijkstra算法数据结构:
图的存储结构:带权的邻接矩阵存储结构。
数组dist[n]:每个分量dist[i]表示当前所找到的从始点v到终点vi的最短路径的长度。初态为:若从v到vi有弧,则dist[i]为弧上权值;否则置dist[i]为∞。
数组path[n]:path[i]是一个字符串,表示当前所找到的从始点v到终点vi的最短路径。初态为:若从v到vi有弧,则path[i]为vvi;否则置path[i]空串。
数组s[n]:存放源点和已经生成的终点,其初态为只有一个源点v。
Dijkstra算法——伪代码
1. 初始化数组dist、path和s;
2. while (s中的元素个数<n)
2.1 在dist[n]中求最小值,其下标为k;
2.2 输出dist[j]和path[j];
2.3 修改数组dist和path;
2.4 将顶点vk添加到数组s中;
这里还有一个比较重要的技术,松弛技术。
bellman-ford,spfa,dijkstra算法都使用了松弛技术,对每个顶点v∈V,都设置一个属性d[v],用来描述从源点s到v的最短路径上权值的上界。
伪代码:
初始化:
Init(G,s)
{
for each vertex v∈V[G]
do d[v]=∞;
d[s]=0;
}
松弛:
Relax(u,v,w)
if(d[v]>d[u]+w(u,v))
d[v]=d[u]+w(u,v)
Dijkstra的缺陷在于它不能处理负权回路:Dijkstra对于标记过的点就不再进行更新了,所以即使有负权导致最短距离的改变也不会重新计算已经计算过的结果,Bellman-Ford算法很好的解决了这个问题。
Bellman-Ford算法思想构造一个最短路径长度数组序列dist 1[u], dist 2 [u], …, dist n-1 [u]。其中:
dist 1 [u]为从源点v到终点u的只经过一条边的最短路径长度,并有dist 1 [u] =Edge[v][u];
dist 2 [u]为从源点v最多经过两条边到达终点u的最短路径长度;
dist 3 [u]为从源点v出发最多经过不构成负权值回路的三条边到达终点u的最短路径长度;
……
dist n-1 [u]为从源点v出发最多经过不构成负权值回路的n-1条边到达终点u的最短路径长度;
算法的最终目的是计算出dist n-1 [u],为源点v到顶点u的最短路径长度。
distk[u]的计算
采用递推方式计算 dist k [u]。
设已经求出 dist k-1 [u] , u =0, 1, …, n-1,此即从源点v最多经过不构成负权值回路的k-1条边到达终点u的最短路径的长度。
从图的邻接矩阵可以找到各个顶点j到达顶点u的距离Edge[j][u],计算min{dist k-1 [j] + Edge[j][u] } ,可得从源点v绕过各个顶点,最多经过不构成负权值回路的k条边到达终点u的最短路径的长度。
比较dist k-1 [u]和min{ dist k-1 [j] + Edge[j][u] } ,取较小者作为distk [u]的值。
递推公式(求顶点u到源点v的最短路径):
dist 1 [u] = Edge[v][u]
dist k [u] = min{ dist k-1[u], min{ dist k-1 [j] + Edge[j][u] } }, j=0,1,…,n-1,j≠u
spfa算法(Shortest path faster algorithm)
SPFA 其实就是Bellman-Ford的一种队列实现,减少了冗余,即松驰的边至少不会以一个d为∞的点为起点。
算法实现:
1.队列Q={s}
2.取出队头u,枚举所有的u的临边 .若d(v)>d(u)+w(u,v)则改进,pre(v)=u,由于d(v)减少了,v可能在以后改进其他的点,所以若v不在Q中,则将v入队。
3.一直迭代2,直到队列Q为空(正常结束),或有的点的入队次数>=n(含有负圈)。
一般用于找负圈(效率高于Bellman-Ford),稀疏图的最短路
每对顶点的最短路径floyd-washall算法的基本思想:
递推产生矩阵序列f(0), f(1), …, f(n)。
其中f(0)[i,j]=map[i,j]
f(k)[i,j]的值表示从vi到vj,中间结点编号不超过k的最短路径长度。
f(n)[i,j]的值就是从vi到vj的最短路径长度。
递推过程----动态规划
如果f(k)[i,j]中间节点编号经过k,则
f(k)[i,j]=f(k-1)[i,k]+f(k-1)[k,j]。如果f(k)[i,j]中间节点不经过k,则f(k)[i,j]=f(k-1)[i,j]。
如果f(k-1)[i,j]<=f(k-1)[i,k]+f(k-1)[k,j],
那么就令f(k)[i,j]= f(k-1)[i,j]。
如果f(k-1)[i,j]>f(k-1)[i,k]+f(k-1)[k,j],
那么就令f(k)[i,j]=f(k-1)[i,k]+f(k-1)[k,j]。
实现:
for(k=1;k<=n;k++)
for(i=1;i<=n;i++)
for(j=1;j<=n;j++)
f[i][j]=min(f[i][j],f[i][k]+f[k][j]);
在图这个专题中,最开始所讲的并查集也很重要,其主要操作就是,合并两个集合,查找某元素属于哪个集合。很多问题在使用并查集后,会变得很简单。
对于这最后的一个专题来说,图算法的实现,主要还是几种算法模板的使用,不过每一个题目,很明显都不可能直接用模板就能够解决,还要有自己的思考过程,根据题意进行灵活的变通。
小结:尽管课程已经结束,但整个学习结束之后,还是学到了很多东西,每一个专题题目的思索,每一个算法的学习,在潜移默化中学到了不少的东西,加强了自己的思考能力。
就自己而言,最大的感触还是,无论学习怎样的知识,都应当多去做题,加深自己的理解,当然也绝不能够盲目,要适度的选择,如此才能够有长足的进步。