算法研究系列:精选24个经典的算法[一、A*搜索算法]
----July/编写
==============
博主说明:
1、此经典算法研究系列,第一、二篇文章(后续文章越写越好),写的不够好,还望见谅。
2、本经典算法研究系列,系我参考资料,一篇一篇原创所作,转载必须注明作者本人July及出处。
3、本经典算法研究系列,会永久更新,希望,尽我所能,阐述尽可能多的世界经典算法。
欢迎,各位,与我一同学习探讨,交流研究。
有误之处,不吝指正。
-------------------------------------------
一、A* 搜寻算法
1968年,的一篇论文,“P. E. Hart, N. J. Nilsson, and B. Raphael. A formal basis for the heuristic determination of minimum cost paths in graphs. IEEE Trans. Syst. Sci. and Cybernetics, SSC-4(2):100-107, 1968”。从此,一种精巧、高效的算法------A*算法横空出世了,并在相关领域得到了广泛的应用。
DFS和BFS在展开子结点时均属于盲目型搜索,也就是说,它不会选择哪个结点在下一次搜索中更优而去跳转到该结点进行下一步的搜索。在运气不好的情形中,均需要试探完整个解集空间, 显然,只能适用于问题规模不大的搜索问题中。
那么,作为启发式算法中的A*算法,又比它们高效在哪里呢?
首先要来谈一下什么是启发式算法。所谓启发式搜索,与DFS和BFS这类盲目型搜索最大的不同,就在于当前搜索结点往下选择下一步结点时,可以通过一个启发函数来进行选择,选择代价最少的结点作为下一步搜索结点而跳转其上(遇到有一个以上代价最少的结点,不妨选距离当前搜索点最近一次展开的搜索点进行下一步搜索)。一个经过仔细设计的启发函数,往往在很快的时间内就可得到一个搜索问题的最优解,对于NP问题,亦可在多项式时间内得到一个较优解。
是的,关键就是如何设计这个启发函数。
A*算法,作为启发式算法中很重要的一种,被广泛应用在最优路径求解和一些策略设计的问题中。而A*算法最为核心的部分,就在于它的一个估值函数的设计上:
f(n)=g(n)+h(n)
其中f(n)是每个可能试探点的估值,它有两部分组成:
一部分为g(n),它表示从起始搜索点到当前点的代价(通常用某结点在搜索树中的深度来表示)。
另一部分,即h(n),它表示启发式搜索中最为重要的一部分,即当前结点到目标结点的估值,
h(n)设计的好坏,直接影响着具有此种启发式函数的启发式算法的是否能称为A*算法。
一种具有f(n)=g(n)+h(n)策略的启发式算法能成为A*算法的充分条件是:
1)搜索树上存在着从起始点到终了点的最优路径。
2)问题域是有限的。
3)所有结点的子结点的搜索代价值>0。
4)h(n)=
对于一个搜索问题,显然,条件1,2,3都是很容易满足的,而
条件4): h(n)<=h*(n)是需要精心设计的,由于h*(n)显然是无法知道的,
所以,一个满足条件4)的启发策略h(n)就来的难能可贵了。
不过,对于图的最优路径搜索和八数码问题,有些相关策略h(n)不仅很好理解,而且已经在理论上证明是满足条件4)的,从而为这个算法的推广起到了决定性的作用。不过h(n)距离h*(n)的呈度不能过大,否则h(n)就没有过强的区分能力,算法效率并不会很高。对一个好的h(n)的评价是:h(n)在h*(n)的下界之下,并且尽量接近h*(n).
二、继续深入之前,再来看下维基百科对本A*搜索算法的解释:
A*搜寻算法,俗称A星算法。这是一种在图形平面上,有多个节点的路径,求出最低通过成本的算法。常用于游戏中的NPC的移动计算,或线上游戏的BOT的移动计算上。
该算法像Dijkstra算法一样,可以找到一条最短路径;也像BFS一样,进行启发式的搜索。
在此算法中,g(n)表示从起点到任意顶点n的实际距离,h(n)表示任意顶点n到目标顶点的估算距离。因此,A*算法的公式为:f(n)=g(n)+h(n)。这个公式遵循以下特性:
如果h(n)为0,只需求出g(n),即求出起点到任意顶点n的最短路径,则转化为单源最短路径问题,即Dijkstra算法
如果h(n)<=n到目标的实际距离,则一定可以求出最优解。而且h(n)越小,需要计算的节点越多,算法效率越低。
三、ok,来看下,此A*搜寻算法的算法实现:
closedset := the empty set //已经被估算的节点集合
openset := set containing the initial node //将要被估算的节点集合
g_score[start] := 0 //g(n)
h_score[start] := heuristic_estimate_of_distance(start, goal) //f(n)
f_score[start] := h_score[start]
while openset is not empty
x := the node in openset having the lowest f_score[] value
if x = goal
return reconstruct_path(came_from,goal)
remove x from openset
add x to closedset
for each y in neighbor_nodes(x)
if y in closedset
continue
tentative_g_score := g_score[x] + dist_between(x,y)
if y not in openset
add y to openset
tentative_is_better := true
else if tentative_g_score < g_score[y]
tentative_is_better := true
else
tentative_is_better := false
if tentative_is_better = true
came_from[y] := x
g_score[y] := tentative_g_score
h_score[y] := heuristic_estimate_of_distance(y, goal)
f_score[y] := g_score[y] + h_score[y]
return failure
function reconstruct_path(came_from,current_node)
if came_from[current_node] is set
p = reconstruct_path(came_from,came_from[current_node])
return (p + current_node)
else
return the empty path
四、再看下,A*搜寻算法核心部分的算法实现之C语言版本:
A*算法流程:
首先将起始结点S放入OPEN表,CLOSE表置空,算法开始时:
1、如果OPEN表不为空,从表头取一个结点n,如果为空算法失败
2、n是目标解吗?是,找到一个解(继续寻找,或终止算法);
3、将n的所有后继结点展开,就是从n可以直接关联的结点(子结点),如果不在CLOSE表中,就将它们放入OPEN表,并把S放入CLOSE表,同时计算每一个后继结点的估价值f(n),将OPEN表按f(x)排序,最小的放在表头,重复算法,回到1。
五、最短路径问题,Dijkstra算法与A*
A*是求这样一个和最短路径有关的问题,那单纯的最短路径问题当然可以用A*来算,对于g(n)就是[S,n],在搜索过程中计算,而h(n)我想不出很好的办法,对于一个抽象的图搜索,很难找到很好的h(n),因为h(n)和具体的问题有关。只好是h(n)=0,退为有序搜索,举一个小小的例子:
V0->V1,V2,V3,V4,V5的路径之和。即好比一个人从V0点出发,要达到V1..V5,五个点。图中达到过的点,可以作为到达其它点的连通桥。下面的问题,即是求此最短路径。
//是的,图片是引用rickone 的。
与结点写在一起的数值表示那个结点的价值f(n),
当OPEN表为空时CLOSE表中将求得从V0到其它所有结点的最短路径。
考虑到算法性能,外循环中每次从OPEN表取一个元素,共取了n次(共n个结点),每次展开一个结点的后续结点时,需O(n)次,同时再对OPEN表做一次排序,OPEN表大小是O(n)量级的,若用快排就是O(nlogn),乘以外循环总的复杂度是O(n^2logn),
如果每次不是对OPEN表进行排序,因为总是不断地有新的结点添加进来,所以不用进行排序,而是每次从OPEN表中求一个最小的,那只需要O(n)的复杂度,所以总的复杂度为O(n*n),这相当于Dijkstra算法。
在这个算法基础之上稍加改进就是Dijkstra算法。OPEN表中常出现这样的表项:(Vk,fk1)(Vk,fk2)(Vk,fk3),而从算法上看,只有fk最小的一个才有用,于是可以将它们合并,整个OPEN表表示当前的从V0到其它各点的最短路径,定长为n,且初始时为V0可直接到达的权值(不能到达为INFINITY),于是就成了Dijkstra算法。
本文完。
本文主要参考:算法导论 第二版、维基百科。
------------------------------------
一、A*搜索算法
三、dynamic programming
二、Dijkstra 算法
五(续)、教你透彻了解红黑树
五、红黑树算法的实现与剖析
六、教你从头到尾彻底理解KMP算法
四、BFS和DFS优先搜索算法
--------------------------------------
在上一篇文中,介绍的A*搜索算法]
http://blog.csdn.net/v_JULY_v/archive/2010/12/23/6093380.aspx中,
提到了此Dijkstra 算法,那么本篇文章,就来研究Dijkstra 算法。
Dijkstra 算法,又叫迪科斯彻算法(Dijkstra),
是由荷兰计算机科学家艾兹格·迪科斯彻(Edsger Wybe Dijkstra)发明的。
算法解决的是有向图中单个源点到其他顶点的最短路径问题。
举例来说,如果图中的顶点表示城市,而边上的权重表示著城市间开车行经的距离,
迪科斯彻算法可以用来找到两个城市之间的最短路径。
Dijkstra 算法在算法导论一书中的第24章 单源最短路径第24.3小节。
由此可知,此算法是为解决有向图G=(V,E)上带权的单源最短路径问题而发明的。
那么什么叫单源最短路径列?通俗的讲,就是最短路线问题。
迪科斯彻算法的输入包含了一个有权重的有向图 G,以及G中的一个来源顶点 S。
我们以 V 表示 G 中所有顶点的集合。每一个图中的边,都是两个顶点所形成的有序元素对。
(u, v) 表示从顶点 u 到 v 有路径相连。我们以 E 所有边的集合,
而边的权重则由权重函数 w: E → [0, ∞] 定义。
因此,w(u, v) 就是从顶点 u 到顶点 v 的非负花费值(cost)。
边的花费可以想像成两个顶点之间的距离。任两点间路径的花费值,就是该路径上所有边的花费值总和。
已知有 V 中有顶点 s 及 t,Dijkstra 算法可以找到 s 到 t 的最低花费路径(例如,最短路径)。
这个算法也可以在一个图中,找到从一个顶点 s 到任何其他顶点的最短路径。
好,咱们来看下此算法的算法实现:
Dijkstra 算法的实现一(维基百科):
u := Extract_Min(Q) 在顶点集合 Q 中搜索有最小的 d[u] 值的顶点 u。这个顶点被从集合 Q 中删除并返回给用户。
1 function Dijkstra(G, w, s)
2 for each vertex v in V[G] // 初始化
3 d[v] := infinity
4 previous[v] := undefined
5 d[s] := 0
6 S := empty set
7 Q := set of all vertices
8 while Q is not an empty set // Dijkstra演算法主體
9 u := Extract_Min(Q)
10 S := S union {u}
11 for each edge (u,v) outgoing from u
12 if d[v] > d[u] + w(u,v) // 拓展边(u,v)
13 d[v] := d[u] + w(u,v)
14 previous[v] := u
如果我们只对在 s 和 t 之间寻找一条最短路径的话,我们可以在第9行添加条件如果满足 u = t 的话终止程序。
现在我们可以通过迭代来回溯出 s 到 t 的最短路径
1 s := empty sequence
2 u := t
3 while defined u
4 insert u to the beginning of S
5 u := previous[u]
现在序列 S 就是从 s 到 t 的最短路径的顶点集.
Dijkstra 算法的实现二(算法导论):
DIJKSTRA(G, w, s)
1 INITIALIZE-SINGLE-SOURCE(G, s)
2 S ← Ø
3 Q ← V[G]
4 while Q ≠ Ø
5 do u ← EXTRACT-MIN(Q)
6 S ← S ∪{u}
7 for each vertex v ∈ Adj[u]
8 do RELAX(u, v, w)
因为Dijkstra算法总是在V-S中选择“最轻”或“最近”的顶点插入到集合S中,所以我们说它使用了贪心策略。
(贪心算法会在日后的博文中详细阐述)。
我们可以用大O符号将迪科斯彻算法的运行时间表示为边数 m 和顶点数 n 的函数。
Dijkstra 算法最简单的实现方法是用一个链表或者数组来存储所有顶点的集合 Q,
所以搜索 Q 中最小元素的运算(Extract-Min(Q))只需要线性搜索 Q 中的所有元素。
这样的话算法的运行时间是 O(n2)。
对于边数少于 n2 的稀疏图来说,我们可以用邻接表来更有效的实现迪科斯彻算法。
同时需要将一个二叉堆或者斐波纳契堆用作优先队列来寻找最小的顶点(Extract-Min)。
当用到二叉堆时候,算法所需的时间为O((m + n)log n),
斐波纳契堆能稍微提高一些性能,让算法运行时间达到O(m + n log n)。
开放最短路径优先(OSPF, Open Shortest Path First)算法是迪科斯彻算法在网络路由中的一个具体实现。与 Dijkstra 算法不同,Bellman-Ford算法可用于具有负花费边的图,只要图中不存在总花费为负值且从源点 s 可达的环路(如果有这样的环路,则最短路径不存在,因为沿环路循环多次即可无限制的降低总花费)。
与最短路径问题相关最有名的一个问题是旅行商问题(Traveling salesman problem),此类问题要求找出恰好通过所有标点一次且最终回到原点的最短路径。然而该问题为NP-完全的;换言之,与最短路径问题不同,旅行商问题不太可能具有多项式时间解法。如果有已知信息可用来估计某一点到目标点的距离,则可改用A*搜寻算法,以减小最短路径的搜索范围。
============
可能,经过上文有点繁杂的信息,你还并不对此算法了如指掌,清晰透彻。
没关系,咱们来幅图,就好了。请允许我再对此算法的概念阐述下,
Dijkstra算法是典型最短路算法,用于计算一个节点到其他所有节点的最短路径。
主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。
[Dijkstra算法能得出最短路径的最优解,但由于它遍历计算的节点很多,所以效率低。]
ok,请看下图:
如下图,设A为源点,求A到其他各顶点(B、C、D、E、F)的最短路径。线上所标注为相邻线段之间的距离,即权值。
(注:此图为随意所画,其相邻顶点间的距离与图中的目视长度不能一一对等)
Dijkstra无向图
算法执行步骤如下表:
是不是对此Dijkstra 算法有不错的了解了。那么,此文也完了。:D。
----July、2010年12月24日。平安夜。
此文,写的实在不怎么样。不过,承蒙大家厚爱,此24个经典算法研究系列的后续文章,个人觉得写的还行。
所以,还请,各位务必关注此算法系列的后续文章。谢谢。
二零一一年一月四日。
ok,咱们先来了解下什么是动态规划算法。
动态规划一般也只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解
(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。
简单地说,问题能够分解成子问题来解决。
动态规划算法分以下4个步骤:
1.描述最优解的结构
2.递归定义最优解的值
3.按自底向上的方式计算最优解的值 //此3步构成动态规划解的基础。
4.由计算出的结果构造一个最优解。 //此步如果只要求计算最优解的值时,可省略。
好,接下来,咱们讨论适合采用动态规划方法的最优化问题的俩个要素:
最优子结构性质,和子问题重叠性质。
一、最优子结构。
如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。
意思就是,总问题包含很多歌子问题,而这些子问题的解也是最优的。
二、重叠子问题。
子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,
有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,
然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,
从而获得较高的效率。
ok,咱们马上进入面试题第56题的求解,即运用经典的动态规划算法:
56.最长公共子序列。
题目:如果字符串一的所有字符按其在字符串中的顺序出现在另外一个字符串二中,
则字符串一称之为字符串二的子串。
注意,并不要求子串(字符串一)的字符必须连续出现在字符串二中。
请编写一个函数,输入两个字符串,求它们的最长公共子串,并打印出最长公共子串。
例如:输入两个字符串BDCABA和ABCBDAB,字符串BCBA和BDAB都是是它们的最长公共子串,
则输出它们的长度4,并打印任意一个子串。
分析:求最长公共子串(Longest Common Subsequence, LCS)是一道非常经典的动态规划题,
因此一些重视算法的公司像MicroStrategy都把它当作面试题。
步骤一、描述一个最长公共子序列
先介绍LCS问题的性质:记Xm={x0, x1,…xm-1}和Yn={y0,y1,…,yn-1}为两个字符串,
并设Zk={z0,z1,…zk-1}是X和Y的任意一个LCS,则可得出3条性质:
1. 如果xm-1=yn-1,那么zk-1=xm-1=yn-1,并且Zk-1是Xm-1和Yn-1的一个LCS;
2. 如果xm-1≠yn-1,那么当zk-1≠xm-1时,Z是Xm-1和Y的LCS;
3. 如果xm-1≠yn-1,那么当zk-1≠yn-1时,Z是X和Yn-1的LCS;
下面简单证明一下由上述相应条件得出的这些性质:
1. 如果zk-1≠xm-1,那么我们可以把xm-1(yn-1)加到Z中得到Z’,这样就得到X和Y的一个长度为k+1的公共子串Z’。
这就与长度为k的Z是X和Y的LCS相矛盾了。因此一定有zk-1=xm-1=yn-1。
既然zk-1=xm-1=yn-1,那如果我们删除zk-1(xm-1、yn-1)得到的Zk-1,Xm-1和Yn-1,显然Zk-1是Xm-1和Yn-1的一个公共子串,现在我们证明Zk-1是Xm-1和Yn-1的LCS。用反证法不难证明。假设有Xm-1和Yn-1有一个长度超过k-1的公共子串W,那么我们把加到W中得到W’,那W’就是X和Y的公共子串,并且长度超过k,这就和已知条件相矛盾了。
2. 还是用反证法证明。假设Z不是Xm-1和Y的LCS,则存在一个长度超过k的W是Xm-1和Y的LCS,那W肯定也X和Y的公共子串,而已知条件中X和Y的公共子串的最大长度为k。矛盾。
3. 证明同2。
步骤二、一个递归解
根据上面的性质,我们可以得出如下的思路:
求两字符串Xm={x0, x1,…xm-1}和Yn={y0,y1,…,yn-1}的LCS,
如果xm-1=yn-1,那么只需求得Xm-1和Yn-1的LCS,并在其后添加xm-1(yn-1)即可(上述性质1);
如果xm-1≠yn-1,我们分别求得Xm-1和Y的LCS和Yn-1和X的LCS,并且这两个LCS中较长的一个为X和Y的LCS(上述性质2、3)。
根据上述结论,可得到以下公式,
如果我们记字符串Xi和Yj的LCS的长度为c[i,j],我们可以递归地求c[i,j]:
/ 0 if i<0 or j<0
c[i,j]= c[i-1,j-1]+1 if i,j>=0 and xi=xj
/ max(c[i,j-1],c[i-1,j] if i,j>=0 and xi≠xj
上面的公式用递归函数不难求得。自然想到Fibonacci第n项(本微软等100题系列V0.1版第19题)问题的求解中可知,
直接递归会有很多重复计算,所以,我们用从底向上循环求解的思路效率更高。
为了能够采用循环求解的思路,我们用一个矩阵(参考下文文末代码中的LCS_length)保存下来当前已经计算好了的c[i,j],
当后面的计算需要这些数据时就可以直接从矩阵读取。
另外,求取c[i,j]可以从c[i-1,j-1] 、c[i,j-1]或者c[i-1,j]三个方向计算得到,
相当于在矩阵LCS_length中是从c[i-1,j-1],c[i,j-1]或者c[i-1,j]的某一个各自移动到c[i,j],
因此在矩阵中有三种不同的移动方向:向左、向上和向左上方,其中只有向左上方移动时才表明找到LCS中的一个字符。
于是我们需要用另外一个矩阵(参考下文文末代码中的LCS_direction)保存移动的方向。
步骤三,计算LCS的长度
LCS-LENGTH(X, Y)
1 m ← length[X]
2 n ← length[Y]
3 for i ← 1 to m
4 do c[i, 0] ← 0
5 for j ← 0 to n
6 do c[0, j] ← 0
7 for i ← 1 to m
8 do for j ← 1 to n
9 do if xi = yj
10 then c[i, j] ← c[i - 1, j - 1] + 1
11 b[i, j] ← "↖"
12 else if c[i - 1, j] ≥ c[i, j - 1]
13 then c[i, j] ← c[i - 1, j]
14 b[i, j] ← "↑"
15 else c[i, j] ← c[i, j - 1]
16 b[i, j] ← ←
17 return c and b
此过程LCS-LENGTH以俩个序列X = 〈x1, x2, ..., xm〉 和 Y = 〈y1, y2, ..., yn〉为输入。
它把c[i,j]值填入一个按行计算表项的表c[0 ‥ m, 0 ‥ n] 中, 它还维护b[1 ‥ m, 1 ‥ n] 以简化最优解的构造。
从直觉上看,b[i, j] 指向一个表项,这个表项对应于与在计算 c[i, j]时所选择的最优子问题的解是相同的。
该程序返回表中 b and c , c[m, n] 包含X和Y的一个LCS的长度。
步骤四,构造一个LCS,
PRINT-LCS(b, X, i, j)
1 if i = 0 or j = 0
2 then return
3 if b[i, j] = "↖"
4 then PRINT-LCS(b, X, i - 1, j - 1)
5 print xi
6 elseif b[i, j] = "↑"
7 then PRINT-LCS(b, X, i - 1, j)
8 else PRINT-LCS(b, X, i, j - 1)
该过程的运行时间为O(m+n)。
-------------------------------
ok,最后给出此面试第56题的代码,请君自看:
参考代码如下:
#include "string.h"
// directions of LCS generation
enum decreaseDir {kInit = 0, kLeft, kUp, kLeftUp};
// Get the length of two strings' LCSs, and print one of the LCSs
// Input: pStr1 - the first string
// pStr2 - the second string
// Output: the length of two strings' LCSs
int LCS(char* pStr1, char* pStr2)
{
if(!pStr1 || !pStr2)
return 0;
size_t length1 = strlen(pStr1);
size_t length2 = strlen(pStr2);
if(!length1 || !length2)
return 0;
size_t i, j;
// initiate the length matrix
int **LCS_length;
LCS_length = (int**)(new int[length1]);
for(i = 0; i < length1; ++ i)
LCS_length[i] = (int*)new int[length2];
for(i = 0; i < length1; ++ i)
for(j = 0; j < length2; ++ j)
LCS_length[i][j] = 0;
// initiate the direction matrix
int **LCS_direction;
LCS_direction = (int**)(new int[length1]);
for( i = 0; i < length1; ++ i)
LCS_direction[i] = (int*)new int[length2];
for(i = 0; i < length1; ++ i)
for(j = 0; j < length2; ++ j)
LCS_direction[i][j] = kInit;
for(i = 0; i < length1; ++ i)
{
for(j = 0; j < length2; ++ j)
{
if(i == 0 || j == 0)
{
if(pStr1[i] == pStr2[j])
{
LCS_length[i][j] = 1;
LCS_direction[i][j] = kLeftUp;
}
else
LCS_length[i][j] = 0;
}
// a char of LCS is found,
// it comes from the left up entry in the direction matrix
else if(pStr1[i] == pStr2[j])
{
LCS_length[i][j] = LCS_length[i - 1][j - 1] + 1;
LCS_direction[i][j] = kLeftUp;
}
// it comes from the up entry in the direction matrix
else if(LCS_length[i - 1][j] > LCS_length[i][j - 1])
{
LCS_length[i][j] = LCS_length[i - 1][j];
LCS_direction[i][j] = kUp;
}
// it comes from the left entry in the direction matrix
else
{
LCS_length[i][j] = LCS_length[i][j - 1];
LCS_direction[i][j] = kLeft;
}
}
}
LCS_Print(LCS_direction, pStr1, pStr2, length1 - 1, length2 - 1); //调用下面的LCS_Pring 打印出所求子串。
return LCS_length[length1 - 1][length2 - 1]; //返回长度。
}
// Print a LCS for two strings
// Input: LCS_direction - a 2d matrix which records the direction of
// LCS generation
// pStr1 - the first string
// pStr2 - the second string
// row - the row index in the matrix LCS_direction
// col - the column index in the matrix LCS_direction
void LCS_Print(int **LCS_direction,
char* pStr1, char* pStr2,
size_t row, size_t col)
{
if(pStr1 == NULL || pStr2 == NULL)
return;
size_t length1 = strlen(pStr1);
size_t length2 = strlen(pStr2);
if(length1 == 0 || length2 == 0 || !(row < length1 && col < length2))
return;
// kLeftUp implies a char in the LCS is found
if(LCS_direction[row][col] == kLeftUp)
{
if(row > 0 && col > 0)
LCS_Print(LCS_direction, pStr1, pStr2, row - 1, col - 1);
// print the char
printf("%c", pStr1[row]);
}
else if(LCS_direction[row][col] == kLeft)
{
// move to the left entry in the direction matrix
if(col > 0)
LCS_Print(LCS_direction, pStr1, pStr2, row, col - 1);
}
else if(LCS_direction[row][col] == kUp)
{
// move to the up entry in the direction matrix
if(row > 0)
LCS_Print(LCS_direction, pStr1, pStr2, row - 1, col);
}
}
扩展:如果题目改成求两个字符串的最长公共子字符串,应该怎么求?
子字符串的定义和子串的定义类似,但要求是连续分布在其他字符串中。
比如输入两个字符串BDCABA和ABCBDAB的最长公共字符串有BD和AB,它们的长度都是2。
附注:算法导论上指出,
一、最长公共子序列问题的一个一般的算法、时间复杂度为O(mn)。
然后,Masek和Paterson给出了一个O(mn/lgn)时间内执行的算法,其中n<=m,而且此序列是从一个有限集合中而来。
在输入序列中没有出现超过一次的特殊情况中,Szymansk说明这个问题可在O((n+m)lg(n+m))内解决。
二、一篇由Gilbert和Moore撰写的关于可变长度二元编码的早期论文中有这样的应用:
在所有的概率pi都是0的情况下构造最优二叉查找树,这篇论文给出一个O(n^3)时间的算法。
Hu和Tucker设计了一个算法,它在所有的概率pi都是0的情况下,使用O(n)的时间和O(n)的空间,
最后,Knuth把时间降到了O(nlgn)。
关于此动态规划算法更多可参考 算法导论一书第15章 动态规划问题,
至于关于此面试第56题的更多,可参考我即将整理上传的答案V04版第41-60题的答案。
完、July、二零一零年十二月三十一日。
----------------------------------------------------------------------------------------------------------
补一网友提供的关于此最长公共子序列问题的java算法源码,我自行测试了下,正确:
import java.util.Random;
public class LCS{
public static void main(String[] args){
//设置字符串长度
int substringLength1 = 20;
int substringLength2 = 20; //具体大小可自行设置
// 随机生成字符串
String x = GetRandomStrings(substringLength1);
String y = GetRandomStrings(substringLength2);
Long startTime = System.nanoTime();
// 构造二维数组记录子问题x[i]和y[i]的LCS的长度
int[][] opt = new int[substringLength1 + 1][substringLength2 + 1];
// 动态规划计算所有子问题
for (int i = substringLength1 - 1; i >= 0; i--){
for (int j = substringLength2 - 1; j >= 0; j--){
if (x.charAt(i) == y.charAt(j))
opt[i][j] = opt[i + 1][j + 1] + 1; //参考上文我给的公式。
else
opt[i][j] = Math.max(opt[i + 1][j], opt[i][j + 1]); //参考上文我给的公式。
}
}
-------------------------------------------------------------------------------------
理解上段,参考上文我给的公式:
根据上述结论,可得到以下公式,
如果我们记字符串Xi和Yj的LCS的长度为c[i,j],我们可以递归地求c[i,j]:
/ 0 if i<0 or j<0
c[i,j]= c[i-1,j-1]+1 if i,j>=0 and xi=xj
/ max(c[i,j-1],c[i-1,j] if i,j>=0 and xi≠xj
-------------------------------------------------------------------------------------
System.out.println("substring1:"+x);
System.out.println("substring2:"+y);
System.out.print("LCS:");
int i = 0, j = 0;
while (i < substringLength1 && j < substringLength2){
if (x.charAt(i) == y.charAt(j)){
System.out.print(x.charAt(i));
i++;
j++;
} else if (opt[i + 1][j] >= opt[i][j + 1])
i++;
else
j++;
}
Long endTime = System.nanoTime();
System.out.println(" Totle time is " + (endTime - startTime) + " ns");
}
//取得定长随机字符串
public static String GetRandomStrings(int length){
StringBuffer buffer = new StringBuffer("abcdefghijklmnopqrstuvwxyz");
StringBuffer sb = new StringBuffer();
Random r = new Random();
int range = buffer.length();
for (int i = 0; i < length; i++){
sb.append(buffer.charAt(r.nextInt(range)));
}
return sb.toString();
}
}
eclipse运行结果为:
substring1:akqrshrengxqiyxuloqk
substring2:tdzbujtlqhecaqgwfzbc
LCS:qheq Totle time is 818058 ns
ok,开始。
翻遍网上,关于此类BFS和DFS算法的文章,很多。但,都说不出个所以然来。
读完此文,我想,
你对图的广度优先搜索和深度优先搜索定会有个通通透透,彻彻底底的认识。
---------------------
咱们由BFS开始:
首先,看下算法导论一书关于 此BFS 广度优先搜索算法的概述。
算法导论第二版,中译本,第324页。
广度优先搜索(BFS)
在Prime最小生成树算法,和Dijkstra单源最短路径算法中,都采用了与BFS 算法类似的思想。
//u 为 v 的先辈或父母。
BFS(G, s)
1 for each vertex u ∈ V [G] - {s}
2 do color[u] ← WHITE
3 d[u] ← ∞
4 π[u] ← NIL
//除了源顶点s之外,第1-4行置每个顶点为白色,置每个顶点u的d[u]为无穷大,
//置每个顶点的父母为NIL。
5 color[s] ← GRAY
//第5行,将源顶点s置为灰色,这是因为在过程开始时,源顶点已被发现。
6 d[s] ← 0 //将d[s]初始化为0。
7 π[s] ← NIL //将源顶点的父顶点置为NIL。
8 Q ← Ø
9 ENQUEUE(Q, s) //入队
//第8、9行,初始化队列Q,使其仅含源顶点s。
10 while Q ≠ Ø
11 do u ← DEQUEUE(Q) //出队
//第11行,确定队列头部Q头部的灰色顶点u,并将其从Q中去掉。
12 for each v ∈ Adj[u] //for循环考察u的邻接表中的每个顶点v
13 do if color[v] = WHITE
14 then color[v] ← GRAY //置为灰色
15 d[v] ← d[u] + 1 //距离被置为d[u]+1
16 π[v] ← u //u记为该顶点的父母
17 ENQUEUE(Q, v) //插入队列中
18 color[u] ← BLACK //u 置为黑色
由下图及链接的演示过程,清晰在目,也就不用多说了:
广度优先遍历演示地址:
http://sjjg.js.zwu.edu.cn/SFXX/sf1/gdyxbl.html
-----------------------------------------------------------------------------------------------------------------
ok,不再赘述。接下来,具体讲解深度优先搜索算法。
深度优先探索算法 DFS
//u 为 v 的先辈或父母。
DFS(G)
1 for each vertex u ∈ V [G]
2 do color[u] ← WHITE
3 π[u] ← NIL
//第1-3行,把所有顶点置为白色,所有π 域被初始化为NIL。
4 time ← 0 //复位时间计数器
5 for each vertex u ∈ V [G]
6 do if color[u] = WHITE
7 then DFS-VISIT(u) //调用DFS-VISIT访问u,u成为深度优先森林中一棵新的树
//第5-7行,依次检索V中的顶点,发现白色顶点时,调用DFS-VISIT访问该顶点。
//每个顶点u 都对应于一个发现时刻d[u]和一个完成时刻f[u]。
DFS-VISIT(u)
1 color[u] ← GRAY //u 开始时被发现,置为白色
2 time ← time +1 //time 递增
3 d[u] <-time //记录u被发现的时间
4 for each v ∈ Adj[u] //检查并访问 u 的每一个邻接点 v
5 do if color[v] = WHITE //如果v 为白色,则递归访问v。
6 then π[v] ← u //置u为 v的先辈
7 DFS-VISIT(v) //递归深度,访问邻结点v
8 color[u] <-BLACK //u 置为黑色,表示u及其邻接点都已访问完成
9 f [u] ▹ time ← time +1 //访问完成时间记录在f[u]中。
//完
第1-3行,5-7行循环占用时间为O(V),此不包括调用DFS-VISIT的时间。
对于每个顶点v(-V,过程DFS-VISIT仅被调用依次,因为只有对白色顶点才会调用此过程。
第4-7行,执行时间为O(E)。
因此,总的执行时间为O(V+E)。
下面的链接,给出了深度优先搜索的演示系统:
图的深度优先遍历演示系统:
http://sjjg.js.zwu.edu.cn/SFXX/sf1/sdyxbl.html
===============
最后,咱们再来看深度优先搜索的递归实现与非递归实现
1、DFS 递归实现:
void dftR(PGraphMatrix inGraph)
{
PVexType v;
assertF(inGraph!=NULL,"in dftR, pass in inGraph is null/n");
printf("/n===start of dft recursive version===/n");
for(v=firstVertex(inGraph);v!=NULL;v=nextVertex(inGraph,v))
if(v->marked==0)
dfsR(inGraph,v);
printf("/n===end of dft recursive version===/n");
}
void dfsR(PGraphMatrix inGraph,PVexType inV)
{
PVexType v1;
assertF(inGraph!=NULL,"in dfsR,inGraph is null/n");
assertF(inV!=NULL,"in dfsR,inV is null/n");
inV->marked=1;
visit(inV);
for(v1=firstAdjacent(inGraph,inV);v1!=NULL;v1=nextAdjacent(inGraph,inV,v1))
//v1当为v的邻接点。
if(v1->marked==0)
dfsR(inGraph,v1);
}
2、DFS 非递归实现
非递归版本---借助结点类型为队列的栈实现
联系树的前序遍历的非递归实现:
可知,其中无非是分成“探左”和“访右”两大块访右需借助栈中弹出的结点进行.
在图的深度优先搜索中,同样可分成“深度探索”和“回访上层未访结点”两块:
1、图的深度探索这样一个过程和树的“探左”完全一致,
只要对已访问过的结点作一个判定即可。
2、而图的回访上层未访结点和树的前序遍历中的“访右”也是一致的.
但是,对于树而言,是提供rightSibling这样的操作的,因而访右相当好实现。
在这里,若要实现相应的功能,考虑将每一个当前结点的下层结点中,如果有m个未访问结点,
则最左的一个需要访问,而将剩余的m-1个结点按从左到右的顺序推入一个队列中。
并将这个队列压入一个堆栈中。
这样,当当前的结点的邻接点均已访问或无邻接点需要回访时,
则从栈顶的队列结点中弹出队列元素,将队列中的结点元素依次出队,
若已访问,则继续出队(当当前队列结点已空时,则继续出栈,弹出下一个栈顶的队列),
直至遇到有未访问结点(访问并置当前点为该点)或直到栈为空(则当前的深度优先搜索树停止搜索)。
将算法通过精简过的C源程序的方式描述如下:
//dfsUR:功能从一个树的某个结点inV发,以深度优先的原则访问所有与它相邻的结点
void dfsUR(PGraphMatrix inGraph,PVexType inV)
{
PSingleRearSeqQueue tmpQ; //定义临时队列,用以接受栈顶队列及压栈时使用
PSeqStack testStack; //存放当前层中的m-1个未访问结点构成队列的堆栈.
//一些变量声明,初始化动作
//访问当前结点
inV->marked=1; //当marked值为1时将不必再访问。
visit(inV);
do
{
flag2=0;
//flag2是一个重要的标志变量,用以、说明当前结点的所有未访问结点的个数,两个以上的用2代表
//flag2:0:current node has no adjacent which has not been visited.
//1:current node has only one adjacent node which has not been visited.
//2:current node has more than one adjacent node which have not been visited.
v1=firstAdjacent(inGraph,inV); //邻接点v1
while(v1!=NULL) //访问当前结点的所有邻接点
{
if(v1->marked==0) //..
{
if(flag2==0) //当前结点的邻接点有0个未访问
{
//首先,访问最左结点
visit(v1);
v1->marked=1; //访问完成
flag2=1; //
//记录最左儿子
lChildV=v1;
//save the current node's first unvisited(has been visited at this time)adjacent node
}
else if(flag2==1) //当前结点的邻接点有1个未访问
{
//新建一个队列,申请空间,并加入第一个结点
flag2=2;
}
else if(flag2==2)//当前结点的邻接点有2个未被访问
{
enQueue(tmpQ,v1);
}
}
v1=nextAdjacent(inGraph,inV,v1);
}
if(flag2==2)//push adjacent nodes which are not visited.
{
//将存有当前结点的m-1个未访问邻接点的队列压栈
seqPush(testStack,tmpQ);
inV=lChildV;
}
else if(flag2==1)//only has one adjacent which has been visited.
{
//只有一个最左儿子,则置当前点为最左儿子
inV=lChildV;
}
else if(flag2==0)
//has no adjacent nodes or all adjacent nodes has been visited
{
//当当前的结点的邻接点均已访问或无邻接点需要回访时,则从栈顶的队列结点中弹出队列元素,
//将队列中的结点元素依次出队,若已访问,则继续出队(当当前队列结点已空时,
//则继续出栈,弹出下一个栈顶的队列),直至遇到有未访问结点(访问并置当前点为该点)或直到栈为空。
flag=0;
while(!isNullSeqStack(testStack)&&!flag)
{
v1=frontQueueInSt(testStack); //返回栈顶结点的队列中的队首元素
deQueueInSt(testStack); //将栈顶结点的队列中的队首元素弹出
if(v1->marked==0)
{
visit(v1);
v1->marked=1;
inV=v1;
flag=1;
}
}
}
}while(!isNullSeqStack(testStack));//the algorithm ends when the stack is null
}
-----------------------------
上述程序的几点说明:
所以,这里应使用的数据结构的构成方式应该采用下面这种形式:
1)队列的实现中,每个队列结点均为图中的结点指针类型.
定义一个以队列尾部下标加队列长度的环形队列如下:
struct SingleRearSeqQueue;
typedef PVexType QElemType;
typedef struct SingleRearSeqQueue* PSingleRearSeqQueue;
struct SingleRearSeqQueue
{
int rear;
int quelen;
QElemType dataPool[MAXNUM];
};
其余基本操作不再赘述.
2)堆栈的实现中,每个堆栈中的结点元素均为一个指向队列的指针,定义如下:
#define SEQ_STACK_LEN 1000
#define StackElemType PSingleRearSeqQueue
struct SeqStack;
typedef struct SeqStack* PSeqStack;
struct SeqStack
{
StackElemType dataArea[SEQ_STACK_LEN];
int slot;
};
为了提供更好的封装性,对这个堆栈实现两种特殊的操作
2.1) deQueueInSt操作用于将栈顶结点的队列中的队首元素弹出.
void deQueueInSt(PSeqStack inStack)
{
if(isEmptyQueue(seqTop(inStack))||isNullSeqStack(inStack))
{
printf("in deQueueInSt,under flow!/n");
return;
}
deQueue(seqTop(inStack));
if(isEmptyQueue(seqTop(inStack)))
inStack->slot--;
}
2.2) frontQueueInSt操作用以返回栈顶结点的队列中的队首元素.
QElemType frontQueueInSt(PSeqStack inStack)
{
if(isEmptyQueue(seqTop(inStack))||isNullSeqStack(inStack))
{
printf("in frontQueueInSt,under flow!/n");
return '/r';
}
return getHeadData(seqTop(inStack));
}
===================
ok,本文完。
July、二零一一年一月一日。Happy 2011 new year!
作者声明:
本人July对本博客所有任何内容和资料享有版权,转载请注明作者本人July及出处。
永远,向您的厚道致敬。谢谢。July、二零一零年十二月二日。
红黑树系列,四篇文章于今日已经完成。[二零一一年一月九日]
教你透彻了解红黑树:
http://blog.csdn.net/v_JULY_v/archive/2010/12/29/6105630.aspx
红黑树算法的层层剖析与逐步实现
http://blog.csdn.net/v_JULY_v/archive/2010/12/31/6109153.aspx
教你彻底实现红黑树:红黑树的c源码实现与剖析
http://blog.csdn.net/v_JULY_v/archive/2011/01/03/6114226.aspx
一步一图一代码,一定要让你真正彻底明白红黑树 [最后更新]
http://blog.csdn.net/v_JULY_v/archive/2011/01/09/6124989.aspx
---------------------------------------------------------------
昨天下午画红黑树画了好几个钟头,总共10页纸。
特此,再深入剖析红黑树的算法实现,教你如何彻底实现红黑树算法。
经过我上一篇博文,“教你透彻了解红黑树”后,相信大家对红黑树已经有了一定的了解。
个人觉得,这个红黑树,还是比较容易懂的。
不论是插入、还是删除,不论是左旋还是右旋,最终的目的只有一个:
即保持红黑树的5个性质,不得违背。
再次,重述下红黑树的五个性质:
一般的,红黑树,满足一下性质,即只有满足一下性质的树,我们才称之为红黑树:
1)每个结点要么是红的,要么是黑的。
2)根结点是黑的。
3)每个叶结点,即空结点(NIL)是黑的。
4)如果一个结点是红的,那么它的俩个儿子都是黑的。
5)对每个结点,从该结点到其子孙结点的所有路径上包含相同数目的黑结点。
抓住了红黑树的那5个性质,事情就好办多了。
如,
1.红黑红黑,要么是红,要么是黑;
2.根结点是黑;
3.每个叶结点是黑;
4.一个红结点,它的俩个儿子必然都是黑的;
5.每一条路径上,黑结点的数目等同。
五条性质,合起来,来句顺口溜就是:(1)红黑 (2)黑 (3)黑 (4&5)红->黑 黑。
本文所有的文字,都是参照我昨下午画的十张纸(即我拍的照片)与算法导论来写的。
希望,你依照此文一点一点的往下看,看懂此文后,你对红黑树的算法了解程度,一定大增不少。
ok,现在咱们来具体深入剖析红黑树的算法,并教你逐步实现此算法。
此教程分为10个部分,每一个部分作为一个小节。且各小节与我给的十张照片一一对应。
一、左旋与右旋
先明确一点:为什么要左旋?
因为红黑树插入或删除结点后,树的结构发生了变化,从而可能会破坏红黑树的性质。
为了维持插入、或删除结点后的树,仍然是一颗红黑树,所以有必要对树的结构做部分调整,从而恢复红黑树的原本性质。
而为了恢复红黑性质而作的动作包括:
结点颜色的改变(重新着色),和结点的调整。
这部分结点调整工作,改变指针结构,即是通过左旋或右旋而达到目的。
从而使插入、或删除结点的树重新成为一颗新的红黑树。
ok,请看下图:
如上图所示,‘找茬’
如果你看懂了上述俩幅图有什么区别时,你就知道什么是“左旋”,“右旋”。
在此,着重分析左旋算法:
左旋,如图所示(左->右),以x->y之间的链为“支轴”进行,
使y成为该新子树的根,x成为y的左孩子,而y的左孩子则成为x的右孩子。
算法很简单,还有注意一点,各个结点从左往右,不论是左旋前还是左旋后,结点大小都是从小到大。
左旋代码实现,分三步(注意我给的注释):
The pseudocode for LEFT-ROTATE assumes that right[x] ≠ nil[T] and that the root's parent is nil[T].
LEFT-ROTATE(T, x)
1 y ← right[x] ▹ Set y.
2 right[x] ← left[y] //开始变化,y的左孩子成为x的右孩子
3 if left[y] !=nil[T]
4 then p[left[y]] <- x
5 p[y] <- p[x] //y成为x的父母
6 if p[x] = nil[T]
7 then root[T] <- y
8 else if x = left[p[x]]
9 then left[p[x]] ← y
10 else right[p[x]] ← y
11 left[y] ← x //x成为y的左孩子(一月三日修正)
12 p[x] ← y
//注,此段左旋代码,原书第一版英文版与第二版中文版,有所出入。
//个人觉得,第二版更精准。所以,此段代码以第二版中文版为准。
左旋、右旋都是对称的,且都是在O(1)时间内完成。因为旋转时只有指针被改变,而结点中的所有域都保持不变。
最后,贴出昨下午关于此右旋算法所画的图:
左旋(第2张图):
//此图有点bug。第4行的注释移到第11行。如上述代码所示。(一月三日修正)
二、左旋的一个实例
不做过多介绍,看下副图,一目了然。
LEFT-ROTATE(T, x)的操作过程(第3张图):
---------------------
提醒,看下文之前,请首先务必明确,区别以下俩种操作:
1.红黑树插入、删除结点的操作
//如插入中,红黑树插入结点操作:RB-INSERT(T, z)。
2.红黑树已经插入、删除结点之后,
为了保持红黑树原有的红黑性质而做的恢复与保持红黑性质的操作。
//如插入中,为了恢复和保持原有红黑性质,所做的工作:RB-INSERT-FIXUP(T, z)。
ok,请继续。
三、红黑树的插入算法实现
RB-INSERT(T, z) //注意我给的注释...
1 y ← nil[T] // y 始终指向 x 的父结点。
2 x ← root[T] // x 指向当前树的根结点,
3 while x ≠ nil[T]
4 do y ← x
5 if key[z] < key[x] //向左,向右..
6 then x ← left[x]
7 else x ← right[x] // 为了找到合适的插入点,x 探路跟踪路径,直到x成为NIL 为止。
8 p[z] ← y // y置为 插入结点z 的父结点。
9 if y = nil[T]
10 then root[T] ← z
11 else if key[z] < key[y]
12 then left[y] ← z
13 else right[y] ← z //此 8-13行,置z 相关的指针。
14 left[z] ← nil[T]
15 right[z] ← nil[T] //设为空,
16 color[z] ← RED //将新插入的结点z作为红色
17 RB-INSERT-FIXUP(T, z) //因为将z着为红色,可能会违反某一红黑性质,
//所以需要调用RB-INSERT-FIXUP(T, z)来保持红黑性质。
17 行的RB-INSERT-FIXUP(T, z) ,在下文会得到着重而具体的分析。
还记得,我开头说的那句话么,
是的,时刻记住,不论是左旋还是右旋,不论是插入、还是删除,都要记得恢复和保持红黑树的5个性质。
四、调用RB-INSERT-FIXUP(T, z)来保持和恢复红黑性质
RB-INSERT-FIXUP(T, z)
1 while color[p[z]] = RED
2 do if p[z] = left[p[p[z]]]
3 then y ← right[p[p[z]]]
4 if color[y] = RED
5 then color[p[z]] ← BLACK ▹ Case 1
6 color[y] ← BLACK ▹ Case 1
7 color[p[p[z]]] ← RED ▹ Case 1
8 z ← p[p[z]] ▹ Case 1
9 else if z = right[p[z]]
10 then z ← p[z] ▹ Case 2
11 LEFT-ROTATE(T, z) ▹ Case 2
12 color[p[z]] ← BLACK ▹ Case 3
13 color[p[p[z]]] ← RED ▹ Case 3
14 RIGHT-ROTATE(T, p[p[z]]) ▹ Case 3
15 else (same as then clause
with "right" and "left" exchanged)
16 color[root[T]] ← BLACK
//第4张图略:
五、红黑树插入的三种情况,即RB-INSERT-FIXUP(T, z)。操作过程(第5张):
//这幅图有个小小的问题,读者可能会产生误解。图中左侧所表明的情况2、情况3所标的位置都要标上一点。
//请以图中的标明的case1、case2、case3为准。一月三日。
六、红黑树插入的第一种情况(RB-INSERT-FIXUP(T, z)代码的具体分析一)
为了保证阐述清晰,重述下RB-INSERT-FIXUP(T, z)的源码:
RB-INSERT-FIXUP(T, z)
1 while color[p[z]] = RED
2 do if p[z] = left[p[p[z]]]
3 then y ← right[p[p[z]]]
4 if color[y] = RED
5 then color[p[z]] ← BLACK ▹ Case 1
6 color[y] ← BLACK ▹ Case 1
7 color[p[p[z]]] ← RED ▹ Case 1
8 z ← p[p[z]] ▹ Case 1
9 else if z = right[p[z]]
10 then z ← p[z] ▹ Case 2
11 LEFT-ROTATE(T, z) ▹ Case 2
12 color[p[z]] ← BLACK ▹ Case 3
13 color[p[p[z]]] ← RED ▹ Case 3
14 RIGHT-ROTATE(T, p[p[z]]) ▹ Case 3
15 else (same as then clause
with "right" and "left" exchanged)
16 color[root[T]] ← BLACK
//case1表示情况1,case2表示情况2,case3表示情况3.
ok,如上所示,相信,你已看到了。
咱们,先来透彻分析红黑树删除的第一种情况:
插入情况1,z的叔叔y是红色的。
第一种情况,即上述代码的第5-8行:
5 then color[p[z]] ← BLACK ▹ Case 1
6 color[y] ← BLACK ▹ Case 1
7 color[p[p[z]]] ← RED ▹ Case 1
8 z ← p[p[z]] ▹ Case 1
如上图所示,a:z为右孩子,b:z为左孩子。
只有p[z]和y(上图a中A为p[z],D为z,上图b中,B为p[z],D为y)都是红色的时候,才会执行此情况1.
咱们分析下上图的a情况,即z为右孩子时
因为p[p[z]],即c是黑色,所以将p[z]、y都着为黑色(如上图a部分的右边),
此举解决z、p[z]都是红色的问题,将p[p[z]]着为红色,则保持了性质5.
ok,看下我昨天画的图(第6张):
红黑树插入的第一种情况完。
七、红黑树插入的第二种、第三种情况
插入情况2:z的叔叔y是黑色的,且z是右孩子
插入情况3:z的叔叔y是黑色的,且z是左孩子
这俩种情况,是通过z是p[z]的左孩子,还是右孩子区别的。
参照上图,针对情况2,z是她父亲的右孩子,则为了保持红黑性质,左旋则变为情况3,此时z为左孩子,
因为z、p[z]都为黑色,所以不违反红黑性质(注,情况3中,z的叔叔y是黑色的,否则此种情况就变成上述情况1 了)。
ok,我们已经看出来了,情况2,情况3都违反性质4(一个红结点的俩个儿子都是黑色的)。
所以情况2->左旋后->情况3,此时情况3同样违反性质4,所以情况3->右旋,得到上图的最后那部分。
注,情况2、3都只违反性质4,其它的性质1、2、3、5都不违背。
好的,最后,看下我画的图(第7张):
八、接下来,进入红黑树的删除部分。
RB-DELETE(T, z)
1 if left[z] = nil[T] or right[z] = nil[T]
2 then y ← z
3 else y ← TREE-SUCCESSOR(z)
4 if left[y] ≠ nil[T]
5 then x ← left[y]
6 else x ← right[y]
7 p[x] ← p[y]
8 if p[y] = nil[T]
9 then root[T] ← x
10 else if y = left[p[y]]
11 then left[p[y]] ← x
12 else right[p[y]] ← x
13 if y 3≠ z
14 then key[z] ← key[y]
15 copy y's satellite data into z
16 if color[y] = BLACK //如果y是黑色的,
17 then RB-DELETE-FIXUP(T, x) //则调用RB-DELETE-FIXUP(T, x)
18 return y //如果y不是黑色,是红色的,则当y被删除时,红黑性质仍然得以保持。不做操作,返回。
//因为:1.树种各结点的黑高度都没有变化。2.不存在俩个相邻的红色结点。
//3.因为入宫y是红色的,就不可能是根。所以,根仍然是黑色的。
ok,第8张图,不必贴了。
九、红黑树删除之4种情况,RB-DELETE-FIXUP(T, x)之代码
RB-DELETE-FIXUP(T, x)
1 while x ≠ root[T] and color[x] = BLACK
2 do if x = left[p[x]]
3 then w ← right[p[x]]
4 if color[w] = RED
5 then color[w] ← BLACK ▹ Case 1
6 color[p[x]] ← RED ▹ Case 1
7 LEFT-ROTATE(T, p[x]) ▹ Case 1
8 w ← right[p[x]] ▹ Case 1
9 if color[left[w]] = BLACK and color[right[w]] = BLACK
10 then color[w] ← RED ▹ Case 2
11 x p[x] ▹ Case 2
12 else if color[right[w]] = BLACK
13 then color[left[w]] ← BLACK ▹ Case 3
14 color[w] ← RED ▹ Case 3
15 RIGHT-ROTATE(T, w) ▹ Case 3
16 w ← right[p[x]] ▹ Case 3
17 color[w] ← color[p[x]] ▹ Case 4
18 color[p[x]] ← BLACK ▹ Case 4
19 color[right[w]] ← BLACK ▹ Case 4
20 LEFT-ROTATE(T, p[x]) ▹ Case 4
21 x ← root[T] ▹ Case 4
22 else (same as then clause with "right" and "left" exchanged)
23 color[x] ← BLACK
ok,很清楚,在此,就不贴第9张图了。
在下文的红黑树删除的4种情况,详细、具体分析了上段代码。
十、红黑树删除的4种情况
情况1:x的兄弟w是红色的。
情况2:x的兄弟w是黑色的,且w的俩个孩子都是黑色的。
情况3:x的兄弟w是黑色的,w的左孩子是红色,w的右孩子是黑色。
情况4:x的兄弟w是黑色的,且w的右孩子时红色的。
操作流程图:
ok,简单分析下,红黑树删除的4种情况:
针对情况1:x的兄弟w是红色的。
5 then color[w] ← BLACK ▹ Case 1
6 color[p[x]] ← RED ▹ Case 1
7 LEFT-ROTATE(T, p[x]) ▹ Case 1
8 w ← right[p[x]] ▹ Case 1
对策:改变w、p[z]颜色,再对p[x]做一次左旋,红黑性质得以继续保持。
x的新兄弟new w是旋转之前w的某个孩子,为黑色。
所以,情况1转化成情况2或3、4。
针对情况2:x的兄弟w是黑色的,且w的俩个孩子都是黑色的。
10 then color[w] ← RED ▹ Case 2
11 x <-p[x] ▹ Case 2
如图所示,w的俩个孩子都是黑色的,
对策:因为w也是黑色的,所以x和w中得去掉一黑色,最后,w变为红。
p[x]为新结点x,赋给x,x<-p[x]。
针对情况3:x的兄弟w是黑色的,w的左孩子是红色,w的右孩子是黑色。
13 then color[left[w]] ← BLACK ▹ Case 3
14 color[w] ← RED ▹ Case 3
15 RIGHT-ROTATE(T, w) ▹ Case 3
16 w ← right[p[x]] ▹ Case 3
w为黑,其左孩子为红,右孩子为黑
对策:交换w和和其左孩子left[w]的颜色。 即上图的D、C颜色互换。:D。
并对w进行右旋,而红黑性质仍然得以保持。
现在x的新兄弟w是一个有红色右孩子的黑结点,于是将情况3转化为情况4.
针对情况4:x的兄弟w是黑色的,且w的右孩子时红色的。
17 color[w] ← color[p[x]] ▹ Case 4
18 color[p[x]] ← BLACK ▹ Case 4
19 color[right[w]] ← BLACK ▹ Case 4
20 LEFT-ROTATE(T, p[x]) ▹ Case 4
21 x ← root[T] ▹ Case 4
x的兄弟w为黑色,且w的右孩子为红色。
对策:做颜色修改,并对p[x]做一次旋转,可以去掉x的额外黑色,来把x变成单独的黑色,此举不破坏红黑性质。
将x置为根后,循环结束。
最后,贴上最后的第10张图:
ok,红黑树删除的4中情况,分析完成。
结语:只要牢牢抓住红黑树的5个性质不放,而不论是树的左旋还是右旋,
不论是红黑树的插入、还是删除,都只为了保持和修复红黑树的5个性质而已。
顺祝各位, 元旦快乐。完。
July、二零一零年十二月三十日。
-------------------------------------------------------
本人这个经典算法研究系列,目前暂时只写了6篇,正在不断更新中。
已经写或编写的六个算法,如下(会持续、永久更新):
经典算法研究系列:一、A*搜索算法
http://blog.csdn.net/v_JULY_v/archive/2010/12/23/6093380.aspx
经典算法研究系列:二、Dijkstra 算法
http://blog.csdn.net/v_JULY_v/archive/2010/12/24/6096981.aspx
经典算法研究系列:三、动态规划算法解微软一道面试题[第56题]
http://blog.csdn.net/v_JULY_v/archive/2010/12/31/6110269.aspx
经典算法研究系列:四、教你通透彻底理解:BFS和DFS优先搜索算法
http://blog.csdn.net/v_JULY_v/archive/2011/01/01/6111353.aspx
经典算法研究系列:五、红黑树算法的实现与剖析
http://blog.csdn.net/v_JULY_v/archive/2010/12/31/6109153.aspx
经典算法研究系列:六、教你从头到尾彻底理解KMP算法
http://blog.csdn.net/v_JULY_v/archive/2011/01/01/6111565.aspx
有问题,望不吝指正。谢谢。
July、二零一一年一月十二日、更新。
本文参考:数据结构(c语言版) 李云清等编著、算法导论
作者声明:个人July 对此24个经典算法系列,享有版权,转载请注明出处。
引言:
在文本编辑中,我们经常要在一段文本中某个特定的位置找出 某个特定的字符或模式。
由此,便产生了字符串的匹配问题。
本文由简单的字符串匹配算法开始,经Rabin-Karp算法,最后到KMP算法,教你从头到尾彻底理解KMP算法。
来看算法导论一书上关于此字符串问题的定义:
假设文本是一个长度为n的数组T[1...n],模式是一个长度为m<=n的数组P[1....m]。
进一步假设P和T的元素都是属于有限字母表Σ.中的字符。
依据上图,再来解释下字符串匹配问题。目标是找出所有在文本T=abcabaabcaabac中的模式P=abaa所有出现。
该模式仅在文本中出现了一次,在位移s=3处。位移s=3是有效位移。
一、简单的字符串匹配算法
简单的字符串匹配算法用一个循环来找出所有有效位移,
该循环对n-m+1个可能的每一个s值检查条件P[1....m]=T[s+1....s+m]。
NAIVE-STRING-MATCHER(T, P)
1 n ← length[T]
2 m ← length[P]
3 for s ← 0 to n - m
4 do if P[1 ‥ m] = T[s + 1 ‥ s + m]
//对n-m+1个可能的位移s中的每一个值,比较相应的字符的循环必须执行m次。
5 then print "Pattern occurs with shift" s
简单字符串匹配算法,上图针对文本T=acaabc 和模式P=aab。
上述第4行代码,n-m+1个可能的位移s中的每一个值,比较相应的字符的循环必须执行m次。
所以,在最坏情况下,此简单模式匹配算法的运行时间为O((n-m+1)m)。
--------------------------------
下面我再来举个具体例子,并给出一具体运行程序:
对于目的字串target是banananobano,要匹配的字串pattern是nano,的情况,
下面是匹配过程,原理很简单,只要先和target字串的第一个字符比较,
如果相同就比较下一个,如果不同就把pattern右移一下,
之后再从pattern的每一个字符比较,这个算法的运行过程如下图。
//index表示的每n次匹配的情形。
#include
#include
using namespace std;
int match(const string& target,const string& pattern)
{
int target_length = target.size();
int pattern_length = pattern.size();
int target_index = 0;
int pattern_index = 0;
while(target_index < target_length && pattern_index < pattern_length)
{
if(target[target_index]==pattern[pattern_index])
{
++target_index;
++pattern_index;
}
else
{
target_index -= (pattern_index-1);
pattern_index = 0;
}
}
if(pattern_index == pattern_length)
{
return target_index - pattern_length;
}
else
{
return -1;
}
}
int main()
{
cout<
}
//运行结果为4。
上面的算法进间复杂度是O(pattern_length*target_length),
我们主要把时间浪费在什么地方呢,
观查index =2那一步,我们已经匹配了3个字符,而第4个字符是不匹配的,这时我们已经匹配的字符序列是nan,
此时如果向右移动一位,那么nan最先匹配的字符序列将是an,这肯定是不能匹配的,
之后再右移一位,匹配的是nan最先匹配的序列是n,这是可以匹配的。
如果我们事先知道pattern本身的这些信息就不用每次匹配失败后都把target_index回退回去,
这种回退就浪费了很多不必要的时间,如果能事先计算出pattern本身的这些性质,
那么就可以在失配时直接把pattern移动到下一个可能的位置,
把其中根本不可能匹配的过程省略掉,
如上表所示我们在index=2时失配,此时就可以直接把pattern移动到index=4的状态,
kmp算法就是从此出发。
二、KMP算法
1、 覆盖函数(overlay_function)
覆盖函数所表征的是pattern本身的性质,可以让为其表征的是pattern从左开始的所有连续子串的自我覆盖程度。
比如如下的字串,abaabcaba
由于计数是从0始的,因此覆盖函数的值为0说明有1个匹配,对于从0还是从来开始计数是偏好问题,
具体请自行调整,其中-1表示没有覆盖,那么何为覆盖呢,下面比较数学的来看一下定义,比如对于序列
a0a1...aj-1 aj
要找到一个k,使它满足
a0a1...ak-1ak=aj-kaj-k+1...aj-1aj
而没有更大的k满足这个条件,就是说要找到尽可能大k,使pattern前k字符与后k字符相匹配,k要尽可能的大,
原因是如果有比较大的k存在,而我们选择较小的满足条件的k,
那么当失配时,我们就会使pattern向右移动的位置变大,而较少的移动位置是存在匹配的,这样我们就会把可能匹配的结果丢失。
比如下面的序列,
在红色部分失配,正确的结果是k=1的情况,把pattern右移4位,如果选择k=0,右移5位则会产生错误。
计算这个overlay函数的方法可以采用递推,可以想象如果对于pattern的前j个字符,如果覆盖函数值为k
a0a1...ak-1ak=aj-kaj-k+1...aj-1aj
则对于pattern的前j+1序列字符,则有如下可能
⑴ pattern[k+1]==pattern[j+1] 此时overlay(j+1)=k+1=overlay(j)+1
⑵ pattern[k+1]≠pattern[j+1] 此时只能在pattern前k+1个子符组所的子串中找到相应的overlay函数,h=overlay(k),如果此时pattern[h+1]==pattern[j+1],则overlay(j+1)=h+1否则重复(2)过程.
下面给出一段计算覆盖函数的代码:
#include
#include
using namespace std;
void compute_overlay(const string& pattern)
{
const int pattern_length = pattern.size();
int *overlay_function = new int[pattern_length];
int index;
overlay_function[0] = -1;
for(int i=1;i
index = overlay_function[i-1];
//store previous fail position k to index;
while(index>=0 && pattern[i]!=pattern[index+1])
{
index = overlay_function[index];
}
if(pattern[i]==pattern[index+1])
{
overlay_function[i] = index + 1;
}
else
{
overlay_function[i] = -1;
}
}
for(i=0;i
cout<
delete[] overlay_function;
}
int main()
{
string pattern = "abaabcaba";
compute_overlay(pattern);
return 0;
}
运行结果为:
-1
-1
0
0
1
-1
0
1
2
Press any key to continue
-------------------------------------
2、kmp算法
有了覆盖函数,那么实现kmp算法就是很简单的了,我们的原则还是从左向右匹配,但是当失配发生时,我们不用把target_index向回移动,target_index前面已经匹配过的部分在pattern自身就能体现出来,只要动pattern_index就可以了。
当发生在j长度失配时,只要把pattern向右移动j-overlay(j)长度就可以了。
如果失配时pattern_index==0,相当于pattern第一个字符就不匹配,
这时就应该把target_index加1,向右移动1位就可以了。
ok,下图就是KMP算法的过程(红色即是采用KMP算法的执行过程):
ok,最后给出KMP算法实现的c++代码:
#include
#include
#include
using namespace std;
int kmp_find(const string& target,const string& pattern)
{
const int target_length = target.size();
const int pattern_length = pattern.size();
int * overlay_value = new int[pattern_length];
overlay_value[0] = -1;
int index = 0;
for(int i=1;i
index = overlay_value[i-1];
while(index>=0 && pattern[index+1]!=pattern[i])
{
index = overlay_value[index];
}
if(pattern[index+1]==pattern[i])
{
overlay_value[i] = index +1;
}
else
{
overlay_value[i] = -1;
}
}
//match algorithm start
int pattern_index = 0;
int target_index = 0;
while(pattern_index
if(target[target_index]==pattern[pattern_index])
{
++target_index;
++pattern_index;
}
else if(pattern_index==0)
{
++target_index;
}
else
{
pattern_index = overlay_value[pattern_index-1]+1;
}
}
if(pattern_index==pattern_length)
{
return target_index-pattern_index;
}
else
{
return -1;
}
delete [] overlay_value;
}
int main()
{
string source = " annbcdanacadsannannabnna";
string pattern = " annacanna";
cout<
}
//运行结果为 -1.
三、kmp算法的来源
kmp如此精巧,那么它是怎么来的呢,为什么要三个人合力才能想出来。其实就算没有kmp算法,人们在字符匹配中也能找到相同高效的算法。这种算法,最终相当于kmp算法,只是这种算法的出发点不是覆盖函数,不是直接从匹配的内在原理出发,而使用此方法的计算的覆盖函数过程序复杂且不易被理解,但是一但找到这个覆盖函数,那以后使用同一pattern匹配时的效率就和kmp一样了,其实这种算法找到的函数不应叫做覆盖函数,因为在寻找过程中根本没有考虑是否覆盖的问题。
说了这么半天那么这种方法是什么呢,这种方法是就大名鼎鼎的确定的有限自动机(Deterministic finite state automaton DFA),DFA可识别的文法是3型文法,又叫正规文法或是正则文法,既然可以识别正则文法,那么识别确定的字串肯定不是问题(确定字串是正则式的一个子集)。对于如何构造DFA,是有一个完整的算法,这里不做介绍了。在识别确定的字串时使用DFA实在是大材小用,DFA可以识别更加通用的正则表达式,而用通用的构建DFA的方法来识别确定的字串,那这个overhead就显得太大了。
kmp算法的可贵之处是从字符匹配的问题本身特点出发,巧妙使用覆盖函数这一表征pattern自身特点的这一概念来快速直接生成识别字串的DFA,因此对于kmp这种算法,理解这种算法高中数学就可以了,但是如果想从无到有设计出这种算法是要求有比较深的数学功底的。
ok,完。