由于在工作中用到了BFS算法、DFS算法、Dijkstra算法、KSP算法,因此将上述算法的工作原理记录一下,同时用图解的方式解释相应的算法。A*算法由于本文在工作中,还没用过,因此只做了一个简单的介绍。
深度优先搜索算法
深度优先遍历(Depth First Search, 简称 DFS),主要思路是从图中一个未访问的顶点 V 开始,沿着一条路一直走到底,然后从这条路尽头的节点回退到上一个节点,再从另一条路开始走到底…,不断递归重复此过程,直到所有的顶点都遍历完成,它的特点是不撞南墙不回头,先走完一条路,再换一条路继续走。
其伪代码为:
DFS(G,v):
mark v as visited
for each neighbor w of v in Graph G:
if w is not visited:
DFS(G,w)
- 注意,对于加权的图来说,DFS算法计算出来的路径并不一定是最优的,只能说明两个节点之间是连通的。
比如对于下面的图中,如果运行的是DFS算法,则其过程为:
(1)我们从根节点 1 开始遍历,它相邻的节点有 2,3,4,先遍历节点 2,再遍历 2 的子节点 5,然后再遍历 5 的子节点 9。
(2)上图中一条路已经走到底了(9是叶子节点,再无可遍历的节点),此时就从 9 回退到上一个节点 5,看下节点 5 是否还有除 9 以外的节点,没有继续回退到 2,2 也没有除 5 以外的节点,回退到 1,1 有除 2 以外的节点 3,所以从节点 3 开始进行深度优先遍历,如下:
(3)同理从 10 开始往上回溯到 6, 6 没有除 10 以外的子节点,再往上回溯,发现 3 有除 6 以外的子点 7,所以此时会遍历 7。
(4)从 7 往上回溯到 3, 1,发现 1 还有节点 4 未遍历,所以此时沿着 4, 8 进行遍历,这样就遍历完成了。
DFS算法完整的执行过程如下:
时间复杂度分析:
从上图可以看出,每个顶点都只被遍历一次,每条边也都只被遍历一次,因此DFS算法的时间复杂度是O(V+E),其中V表示顶点的个数,E表示边的个数。
广度优先搜索算法
广度优先遍历(Breadth-First Search,简称BFS),指的是从图的一个未遍历的节点出发,先遍历这个节点的相邻节点,再依次遍历每个相邻节点的相邻节点。广度优先搜索算法是一种“地毯式”的搜索策略。
其伪代码为:
BFS(G, start_v):
创建队列Q
创建一个访问标记数组visited
将start_v入队Q
visited[start_v] = true
while Q非空:
取出队列中的第一个节点v
for 每个节点v的邻居w:
if w未被访问:
将w入队Q
visited[w] = true
BFS算法完整的执行过程如下:
时间复杂度分析:
在最坏的情况下,目的节点t距离起始节点s很远,需要遍历完整个图才能找到(比如上面的顶点1到顶点10)。则每个顶点都要进出一次队列,每个边也都会被访问一次,因此,BFS算法的时间复杂度是O(V+E)。
Dijkstra算法
Dijkstra算法用于在加权图中的找出从一个指定起始节点到其他所有节点的最短路径,适用于含有非负权重边的有向和无向图。Dijkstra算法本质是广度优先搜索算法。
算法思想:令G=(V,E)为一个带权有向图,把图中的顶点集合V分成两组,第一组是已求出最短路径的集合visited(初始化时visited中只有源节点,以后每求出一条最短路径,就将它对应的顶点加入到集合visited中,直到全部顶点都加入到visited中);第二组是未确定最短路径的顶点集合candidate。在加入过程中,总保持从源节点v到visited中各顶点的最短路径长度不大于从源节点v到candidate中任何顶点的最短路径长度。
算法步骤:
(1)初始化。visited集合初始化时只有源节点v。candidate集合初始化时有除了源节点v之外的所有节点。将除了源点之外的其他所有节点的最短路径估计值初始化为无穷大(表示尚未找到实际路径)。对于源点,其值初始化为0(从源点到自身的距离)。
(2)选择节点。从源点开始,选择与源点v距离最短的节点作为当前处理节点(假设为k),将当前节点k加入到visited集合中,并且将k从candidate集合中剔除。
(3)松弛操作。对当前处理节点k,修改candidate集合中与当前节点k是邻居的各顶点(设为u)的距离。若从源节点v到顶点u的距离(经过顶点k)比原来距离(不经过顶点k)短,则修改顶点u的距离值,修改后的距离值是顶点k的距离加上k倒u的距离。
(4)**重复步骤。**重复步骤2和(3),直到所有节点都包含在visited集合中。
伪代码:
/*
Graph:图的数据结构
source:源节点
dist:记录了源点到其他所有点的当前估计最短路径
previous:记录了最短路径中每个节点的前继节点
*/
Dijkstra(Graph, source):
// (1) 初始化
for each vertex v in Graph:
dist[v] ≔ ∞
previous[v] ≔ undefined
dist[source] ≔ 0
// 重复步骤
for vertex in set of all vertices:
// (2)选择节点
u ≔ the vertex in V with the smallest dist[u]
if dist[u] = ∞:
break
// 松弛操作
for each edge from u to v:
if dist[v] > dist[u] + weight of the edge:
dist[v] ≔ dist[u] + weight of the edge
previous[v] ≔ u
return previous and dist
以下图为例讲解Dijkstra算法的执行过程(求节点A到其他节点的最短距离)。这里定义两个集合:visited和candidate。其中visited用来存放已经求出最短路径的节点,candidate用来存放还未计算出最短路径的节点。
(1)初始化visited集合和candidate集合,以及各节点到源节点A的距离。此时visited={A},candidate={B,C,D,E,F}。除了节点A外,各节点到A的初始距离都是MAX,表示尚未找到实际路径,而A的距离初始化为0。
(2)从A节点开始,更新节点A的所有邻居到节点A的距离。A的邻居有B和D两个节点,并且这两个点的距离分别是A->B为10,A->D距离为4,而原来A->B的距离为MAX,A->D的距离为MAX,均小于原来的距离,所以要将这两个点到节点A的距离更新。
(3)从candidate集合里面选择一个点加入visited集合,这个点要满足距离源节点A的距离最短,从图中可知,节点D到A节点距离最短,因此我们选择D这个点添加到集合visited,同时将D节点从candidate集合中删除,此时集合visited变为:{A,D},集合candidate变为:{B,C,E,F},节点A到D的路径为A->D,距离为4。如下图所示:
(4)选择刚刚加入的节点D,更新所有与D点邻接的点,因为与D邻接的点有B、C、E,并且这三个点到A的距离分别是:
A->E:A->D D->E=10;
A->B:A->D D->B=6;
A->C:A->D D->C=19;
并且这三个点到A点的距离小于原来它们到A点的距离,所以要将这三个点到节点A的距离更新。
(5)从candidate集合里面选择一个点到源节点A距离最短的点加入visited集合。从图中可知,节点B到A节点距离最短,因此我们选择B这个点添加到集合visited,同时将B节点从candidate中删除,此时集合visited变为:{A,D,B},集合candidate变为:{C,E,F},节点A到B的路径为A->D->B,距离为6。如下图所示:
(6)选择刚刚加入的节点B,更新所有与B点邻接的点,因为与B邻接的点有C、E。这两个点经过B到源节点A的距离分别是:
A->C:A->D D->B B->C=14
A->E:A->D D->B B->E=12
因为A->C的距离是14,A->E的距离是12,而原来A->C的距离是19,A->E的距离是10。因此需要更新A->C的距离为14,而A->E的保持为10不变:
(7)从candidate集合里面选择一个点到源节点A距离最短的点加入visited集合。从图中可知,节点E到A节点距离最短,因此我们选择E这个点添加到集合visited,同时将E节点从candidate中删除,此时集合visited变为:{A,D,B,E},集合candidate变为:{C,F},节点A到E的路径为A->D->E,距离为10,如下图所示:
(8)选择刚刚加入的节点E,更新所有与E点邻接的点,因为与E邻接的点有C、F。这两个点经过E到源节点A的距离分别是:
A->F:A->D->E->F=22
A->C:A->D->E->C=11:
因为A->F的距离是22,A->C的距离是11,而原来A->F的距离是MAX,A->C的距离是14。因此需要更新A->F的距离为22,A->C的距离为11。
(9)从candidate集合里面选择一个点到源节点A距离最短的点加入visited集合。从图中可知,节点C到A节点距离最短,因此我们选择C这个点添加到集合visited,同时将C节点从candidate中删除,此时集合visited变为:{A,D,B,E,C},集合candidate变为:{F},节点A到C的路径为A->D->E->C,距离为11,如下图所示:
(10)选择刚刚加入的节点C,更新所有与C点邻接的点。因为与C邻接的点有F。F点经过C到源节点A的距离是:
A->F:A->D->E->C->F=16,而原来A->F的距离是22,因此需要更新A->F的距离为16。
(11)从candidate集合里面选择一个点到源节点A距离最短的点加入visited集合。由于只有节点F在candidate集合中了,因此选择节点F。添加到集合visited,同时将F节点从candidate中删除,此时集合visited变为:{A,D,B,E,C,F},集合candidate变为:{},节点A到F的路径为A->D->E->C->F,距离为16。由于集合candidate已经为空,因此计算结束。
Dijkstra算法完整的执行过程如下:
时间复杂度分:时间复杂度为O(|V|^2),其中|V|表示顶点的个数。
Dijkstra算法的应用:
Dijkstra算法在很多领域都有应用,比如在网络路由规划、智能交通、无人机和机器人、交通网络等。但是一般不会原始的直接用Dijkstra算法,而是结合具体的场景。
比如,2016年的华为软件精英挑战赛,其中一种常规的方法就是用Dijkstra算法,初赛题目为:
初赛题目:
给定一个带权重的有向图G=(V,E),V为顶点集,E为有向边集,每一条有向边均有一个权重。对于给定的顶点s、t,以及V的子集V',寻找从s到t的不成环有向路径P,使得P经过V'中所有的顶点(对经过V'中节点的顺序不做要求)。
若不存在这样的有向路径P,则输出无解,程序运行时间越短,则视为结果越优;若存在这样的有向路径P,则输出所得到的路径,路径的权重越小,则视为结果越优,在输出路径权重一样的前提下,程序运行时间越短,则视为结果越优。
分析:这个其实就是求一条从源节点到目的节点,且经过指定中间结点的最短路径。
复赛题目:
给定一个带权重的有向图G=(V,E),V为顶点集,E为有向边集,每一条有向边均有一个权重。对于给定的顶点s、t,以及V的子集V’和V’’,寻找从s到t的两条不成环的有向路径P’和P’’,使得P’经过V’中所有的顶点,而P’’经过V’’中所有的顶点(对P’经过V’中顶点的顺序以及P’’经过V’’中顶点的顺序不做要求)。
若不同时存在这样的两条有向路径,则输出无解,程序运行时间越短,则视为结果越优; 若同时存在这样的两条有向路径,则输出得到的两条路径,按下列优先级从高到低评价结果优劣:
1、 路径P’和P’’重合的有向边个数越少,则视为结果越优;
2、 在两条路径重合的有向边个数一样的情况下,两条路径权重总和越少,则视为结果越优;
3、 在上述两个指标一样的情况下,程序运行时间越短,则视为结果越优。
分析:这个是在初赛的基础上,求出两条经过指定中间结点的最短路径,而且这条路径越少重叠越好。
为什么会有这样的需求?因为在实际中,确实是有应用场景的:如果计算源节点到目的节点只算一条路径的话,那如果中间哪个节点突然故障了,那就需要重新计算出一条最短路径,而在计算出这条最短路径之前,业务是受损的,会导致业务中断(比如业务中断10多秒)。但如果一次性计算出多条路径(比如3条),并且这3条路径是尽量分开的,那如果一条路径故障了,我还可以切换到其他路径。这样就会尽可能的减少业务中断时间(一般来说,从一条路径切换到另外一条路径的时间是毫秒级)。
KSP算法
KSP即K-Shortest-Path,即前K条最短路径。K最短路径问题是最短路径问题的扩展和变形。能一次性计算出前K条最短的路径。
有很多计算前K条最短路径的算法。本文主要讲作者在实际工作中用到的Yen算法。
算法思想:算法可分为两部分,首先算出第1条最短路径P(1)。然后在此基础上依次算出其他的K-1条最短路径。在求P(i+1)时,将P(i)上除了终止节点外的所有节点都视为偏离节点,并计算出每个偏离节点到终止节点的最短路径,再与之前的P(i)上起始节点到偏离节点的路径拼接,构成候选路径,进而求得最短偏离路径。
Yen算法步骤:
(1)初始化:首先使用Dijkstra算法或其他最短路径算法找到从起点s到终点t的最短路径,设为P(1)。
(2)递归生成:基于已找到的最短路径,Yen算法通过以下步骤递归地生成后续的最短路径:
- 选择P(i)上的一个非终点作为“偏离节点”。
- 对每个偏离点,使用Dijkstra算法计算从该点到终点t的最短距离。在计算过程中,有些节点或者边需要剔除:
- 需要剔除的节点:为了防止从起点到终点的整体路径有环,从偏离节点到终点t的路径不能包含路径P(i)上从起点s到该偏离节点的任何节点。
- 需要剔除的边:为了避免与已经在结果列表中的路径重复,从当前偏离节点发出的边不能与结果列表中的路径p(1),p(2),…p(i),上从该偏离节点发出边的相同
- 将从偏离点到终点的最短路径与P(i)上从起点到偏离点的路径连接起来,形成一条候选路径。
(3)选择最短偏离路径:从所有候选路径中选择路径长度最小的一条作为P(i+1),即第i+1条最短路径。
(4)重复步骤:重复步骤(2)和步骤(3),直到找到所需的K条最短路径或无法找到新的非重复路径。
下面还是以下图为例,运用Yen算法,计算从节点A到节点F的前3条最短路径。
以下步骤中,用到了以下变量,在此解释一下各变量的含义:
-
spur_path表示偏离节点到终点的路径
-
root_path表示根节点(也就是源节点)到偏离节点的路径
-
total_path表示root_path和spur_path的拼接
-
ignore_nodes表示需要忽略的节点列表
-
ignore_edge表示需要忽略的边列表
-
A表示最终结果路径列表
-
candidate表示候选列表
1、使用Dijkstra算法计算从A到F的一条最短路径A(1)={A->D->E->C->F},并添加到结果列表A中:A={[A->D->E->C->F]}
2、在A(1)的基础上求出第二短路径A2。偏离节点是A(1)上除了终点之外的其他所有节点,即偏离节点有:A、D、E、C
-
(2.1)以A为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes为空,需要忽略的边ignore_edge={[A->D]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[A->B->E->C-F],而此时的root_path为空(因为此时根节点为偏离节点),然后将root_path和spur_path拼接起来,形成total_path=[A->B->E->C-F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]}。
-
(2.2)以D为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A},需要忽略的边ignore_edge={[D->E]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[D->B->E->C-F],而此时的root_path={A->D},然后将root_path和spur_path拼接起来,形成total_path=[A->D->B->E->C-F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]、[A->D->B->E->C-F]}。
-
(2.3)以E为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A,D},需要忽略的边ignore_edge={[E->C]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[E->F],而此时的root_path={A->D->E},然后将root_path和spur_path拼接起来,形成total_path=[A->D->E->F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]、[A->D->B->E->C-F]、[A->D->E->F]}。
-
(2.4)以C为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A,D,E},需要忽略的边ignore_edge={[C->F]},在上述约束条件下,从偏离节点C到终点F计算失败。因此跳过。
-
(2.5)A(1)中除了终点外其他所有节点均已作为偏离节点计算出了候选路径,因此从候选列表candidate={[A->B->E->C-F]、[A->D->B->E->C-F]、[A->D->E->F]}中选择一条最短的路径作为A(2),A(2)=[A->D->B->E->C-F],并将该路径从candidate列表中删除,同时添加到最终结果列表A中。最终,candidate={[A->B->E->C-F]、[A->D->E->F]},A={[A->D->E->C->F]、[A->D->B->E->C-F]}
3、在A(2)=[A->D->B->E->C-F]的基础上求出第三短路径A3。偏离节点是A(2)上除了终点之外的其他所有节点,即偏离节点有:A、D、B、E、C
-
(3.1)以A为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes为空,需要忽略的边ignore_edge={[A->D]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[A->B->E->C-F],而此时的root_path为空(因为此时根节点为偏离节点),然后将root_path和spur_path拼接起来,形成total_path=[A->B->E->C-F],但此时candidate={[A->B->E->C->F]、[A->D->E->F]}中已经包含total_path。因此本次计算的total_path不放到candidate集合中。
-
(3.2)以D为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A},需要忽略的边ignore_edge={[D->B],[D->E]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[D->C-F],而此时的root_path={A->D},然后将root_path和spur_path拼接起来,形成total_path=[A->D->C-F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]、[A->D->E->F]、[A->D->C-F]}。
- (3.3)以B为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A,D},需要忽略的边ignore_edge={[B->E]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[B->C-F],而此时的root_path={A->D->B},然后将root_path和spur_path拼接起来,形成total_path=[A->D->B->C-F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]、[A->D->E->F]、[A->D->C->F]、[A->D->B->C-F]}。
- (3.4)以E为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A,D,B},需要忽略的边ignore_edge={[E->C]},在上述约束条件下,计算出偏离节点到终点F的最短路径为spur_path=[E->F],而此时的root_path={A->D->B->E},然后将root_path和spur_path拼接起来,形成total_path=[A->D->B->E->F],并将此total_path添加到候选路径列表candidate中,candidate={[A->B->E->C-F]、[A->D->E->F]、[A->D->C->F]、[A->D->B->C-F]、[A->D->B->E->F]}。
-
(3.5)以C为偏离节点,计算出偏离节点到终点的最短路径。此时需要忽略的节点ignore_nodes={A,D,B、E},需要忽略的边ignore_edge={[C->F]},在上述约束条件下,从偏离节点C到终点F计算失败。因此跳过。
-
(3.6)A(2)中除了终点外其他所有节点均已作为偏离节点计算出了候选路径,因此从候选列表candidate={[A->B->E->C-F]、[A->D->E->F]、[A->D->C->F]、[A->D->B->C-F]、[A->D->B->E->F]}中选择一条最短的路径作为A(3),A(3)=[A->D->B->C-F],并将该路径从candidate列表中删除,同时添加到最终结果列表A中。最终,candidate={[A->B->E->C-F]、[A->D->E->F]、[A->D->C->F]、[A->D->B->E->F]},A={[A->D->E->C->F]、[A->D->B->E->C-F]、[A->D->B->C-F]}
4、由于已经计算出3条路径,达到预期的数量,因此退出KSP流程。最终计算的3条路径分别是:A={[A->D->E->C->F]、[A->D->B->E->C-F]、[A->D->B->C-F]}
从最终的运算结果上看,KSP算法只关注距离最短的前K条路径,至于其他要求,则不会管。如果我需要计算出来的3条路径能够尽量的分离(尽量节点分离,尽量边分离),那从上面的结果看,肯定是不符合要求的,计算出来的3条路径大部分是重叠的。因此,在实际的应用中,一般需要对KSP算法进行改进或者KSP算法只是一个保底的算法,即可能会先用其他算法算出前K条路径,如果其他算法没有计算出足够的路径,再用KSP算法保底。
KSP算法完整的执行过程如下::
A*算法
说明:因为在实际工作中,本人还未用过此算法,因此A*算法是找资料学习的,如果理解的不正确,请大家指正。
A*算法一种变形的Dijstra算法,其算法过程和Dijstra算法类似。
A*算法,即A*搜寻算法(A-star Algorithm),是一种启发式搜索算法。
算法原理:
A*算法的核心在于其代价函数F,该函数由两部分组成:实际代价G和启发式代价H。即F=G+H。
- 实际代价G:从起始点到当前点的实际移动成本。
- 启发式代价H:从当前点到终点的预估成本,通常基于某种启发式评估方法,如曼哈顿距离或欧几里德距离。启发式代价的大小取决于计算H代价的函数,这个函数被称为启发函数(Heuristic Function)(从这里看,个人感觉选择合适的启发函数应该是一个研究要点)。
算法步骤
1、初始化:设置起点和终点,定义两个列表(或队列)openList和closeList。openList用于存储待探索的节点,closeList用于存储已经探索过的节点。
2、将起点加入openList:初始化起点的G值和H值为0,并将其加入openList。
3、循环探索:重复以下步骤,直到找到终点或openList为空。
- 从openList中选择F值最小的节点作为当前节点,并将其从openList移除,加入closeList
- 遍历当前节点的所有相邻节点,对每个相邻节点进行以下操作:
- 如果节点不可达或已在closeList中,则忽略
- 如果节点不在openList中,则计算其G值、H值和F值,并将其加入openList,同时设置其父节点指针。
- 如果节点已在openList中,但经过当前节点到达该节点的G值更小,则更新其G值、F值和父节点指针。
4、构建最短路径:当找到终点时,从终点开始按照父节点指针逆向回溯,直至回溯到起点,即可得到最短路径。
(对比A*算法的步骤和Dijstra算法的步骤,可以看出它们两本质上是一致的)
参考文献:
1、图文详解两种算法:深度优先遍历(DFS)和广度优先遍历(BFS)_dfs 遍历栈-CSDN博客
2、【算法】图解A* 搜索算法_a*算法流程图-CSDN博客
3、一篇文章讲透Dijkstra最短路径算法 - 金色旭光 - 博客园 (cnblogs.com)
4、迪杰斯特拉(Dijkstra)算法_dijkstra算法-CSDN博客
5、K条最短路径算法(KSP, k-shortest pathes):Yen’s Algorithm - Wasdns - 博客园 (cnblogs.com)