图算法
图算法表示用于对图的算法,常见的广度优先搜索以及深度优先搜索都属于图算法。
以下为我对遇到过的图算法的学习总结。
图的表示
为了对图进行计算,首先要清楚如何表示图。
图有两种标准的方法表示,分别为:
- 邻接表
- 邻接矩阵
有以下有向图:
邻接表
上述有向图的邻接表为:
《算法导论》中对邻接表的说明:
对于图 G = (V, E) 来说,其邻接表表示由一个包含 |V| 条链表的数组 Adj 所构成,每个结点由一条链表。对于每个结点 u ∈ V ,邻接表 Adj[u] 包含所有与结点 u 之间的有边相邻的结点 v ,即 Adj[u] 所包含图中所有与 u 邻接的结点(也可以说,该链表里包含指向这些结点的指针)。
邻接矩阵
上述有向图的邻接矩阵为:
《算法导论》中对邻接矩阵的说明:
邻接表的一个潜在缺陷是无法快速判断一条边 (u, v) 是否是图中的一条边,唯一的办法是在邻接表 Adj[u] 里面搜索结点 v 。邻接矩阵克服了这个缺陷,但付出的代价是更大的存储空间消耗。
对于邻接矩阵表示来说,通常会将图 G 中的结点编为 1, 2, ……, |V| 这种编号可以是任意的。在进行此种编号之后,图 G 的邻接矩阵表示由一个 |V| × |V| 的矩阵 A 予以表示,该矩阵满足下述条件:
除此之外,其实还有一种图的表示方法,那就是对图的抽象表示,即我们常见的二叉树对象,它也是一种对图的表示,只不过是特定于一种类型的图。
拓扑排序
以下为维基上对拓扑排序的定义:
在计算机科学中,拓扑排序是有向图的线性排序,对于图中的每条有向边 (u, v) ,在拓扑排序中,顶点 u 在顶点 v 之前。
可以利用广度优先搜索或是深度优先搜索完成图的拓扑排序。
深度优先搜索完成拓扑排序
深度优先搜索完成拓扑排序很简单,通过深度优先搜索遍历图,当结点所有的出发边都发现后,将结点存储到一个列表中,完成深度优先搜索时,将列表进行倒序,得到的就是图的拓扑排序结果。
不过对图进行拓扑排序,首先要确定进行拓扑排序的图是有向无环图。
而要确认图是否有环,首先需要了解关于深度优先搜索的“着色”。《算法导论》中,在深度优先搜索过程中对各个结点进行“着色”。每个结点初始都为白色,在结点初次被发现后,变为灰色,当扫描完成后(即结点的所有出发边都被发现),结点变为黑色。
再根据《算法导论》中的结论:假如图是有向无环图,那么在进行深度优先搜索时,遇到的结点不可能是灰色的。
简单的分析一下上述的结论,当遇到的结点为白色,即结点初次被发现。而遇到的结点为黑色,即表示该结点在完成了一次扫描,继续沿着这个结点进行遍历,即会重复上一次扫描的行为。而如果遇到灰色结点,表示在一次遍历过程中重复发现了一个结点,而只有图中存在环时,才有可能重复发现结点。
有以下例题:Course Schedule 和 Course Schedule II
两道题的问题本质是一致,Course Schedule 要解决的是给出的图是否能进行拓扑排序,即是否有环,而 Course Schedule II 除了判断能否进行拓扑排序外,还需要给出拓扑排序的结果。
在解决问题的时候,使用 0 表示白色,-1 表示灰色,1 表示黑色。在深度优先搜索的过程中,如果遇到 -1 的结点即表示图有环,不可以进行拓扑排序。
最后需要注意的是,题目需要我们对图进行邻接表的表示。以下分别为解题源码:
class Solution:
def canFinish(self, numCourses, prerequisites):
"""
:type numCourses: int
:type prerequisites: List[List[int]]
:rtype: bool
"""
def dfs(adj, visited, i):
if visited[i] == -1:
return False
if visited[i] == 1:
return True
visited[i] = -1
for node in adj[i]:
if not dfs(adj, visited, node):
return False
visited[i] = 1
return True
adj = [[] for i in range(numCourses)]
visited = [0 for _ in range(numCourses)]
for prerequisite in prerequisites:
adj[prerequisite[1]].append(prerequisite[0])
for i in range(numCourses):
if not dfs(adj, visited, i):
return False
return True
class Solution:
def findOrder(self, numCourses, prerequisites):
"""
:type numCourses: int
:type prerequisites: List[List[int]]
:rtype: List[int]
"""
def dfs(adj, visited, topology_list, i):
if visited[i] == -1:
return False
if visited[i] == 1:
return True
visited[i] = -1
for node in adj[i]:
if not dfs(adj, visited, topology_list, node):
return False
visited[i] = 1
topology_list.append(i)
return True
adj = [[] for i in range(numCourses)]
visited = [0 for _ in range(numCourses)]
for prerequisite in prerequisites:
adj[prerequisite[1]].append(prerequisite[0])
topology_list = []
for i in range(numCourses):
if not dfs(adj, visited, topology_list, i):
return []
return topology_list[::-1]
广度优先搜索完成拓扑排序
不论使用何种方法去完成拓扑排序,判断图是否有环是重要的一环,因为有环图无法完成拓扑排序。
而《数据结构与算法》拓扑排序一节中的做法是先找出一任意一个没有入边的顶点。然后显示出该顶点,并将它及其边一起从图中删除。然后,对图的其余部分同样应用这样的方法处理。如果一个图含有环的话,将会遇到无法删除的结点,即结点总是有入边。
顶点 v 的入度为边 (u, v) 的条数,通过计算、修改顶点的入度来完成拓扑排序。
以下为参考自《数据结构与算法》的拓扑排序的伪代码:
def topsort(graph):
queue = []
# 记录结点出队的顺序,也是结点在拓扑排序中的序号
int counter = 0
for v in graph.V:
# 将入度为 0 的结点入队
if v.indegree == 0:
queue.append(v)
while queue:
v = queue.pop(0)
counter += 1
# 记录结点在拓扑排序中的序号
v.topNum = counter
for w in graph.Adj[v]:
# 将与 v 相连的结点 w 的入度都减去 1 ,以此表示删除结点 v
w.indegree -= 1
if w.indegree == 0:
queue.append(w)
# 当队列为空时,表示拓扑排序处理完成。如果此时完成拓扑排序的结点不等于图的结点个数,即表示有环,存在环时,将有结点因入度始终不为 0 而得不到处理。
if counter != NUM_VERTICES:
raise CycleFoundException()
最后,同样以题目 Course Schedule 和 Course Schedule II 作为例题。
解题思路和上面利用深度优先搜索解决的方法一致,对图进行拓扑排序,判断是否图是否有环。不过这次改为利用广度优先搜索的思路进行拓扑排序。
class Solution:
def canFinish(self, numCourses, prerequisites):
"""
:type numCourses: int
:type prerequisites: List[List[int]]
:rtype: bool
"""
adj = [[] for i in range(numCourses)]
indegree = [0 for _ in range(numCourses)]
for prerequisite in prerequisites:
adj[prerequisite[1]].append(prerequisite[0])
indegree[prerequisite[0]] += 1
queue = []
for i in range(numCourses):
if indegree[i] == 0:
queue.append(i)
counter = 0
while queue:
vertex = queue.pop(0)
counter += 1
for u in adj[vertex]:
indegree[u] -= 1
if indegree[u] == 0:
queue.append(u)
return counter == numCourses
class Solution:
def findOrder(self, numCourses, prerequisites):
"""
:type numCourses: int
:type prerequisites: List[List[int]]
:rtype: List[int]
"""
adj = [[] for i in range(numCourses)]
indegree = [0 for _ in range(numCourses)]
for prerequisite in prerequisites:
adj[prerequisite[1]].append(prerequisite[0])
indegree[prerequisite[0]] += 1
queue = []
for i in range(numCourses):
if indegree[i] == 0:
queue.append(i)
counter = 0
res = []
while queue:
vertex = queue.pop(0)
counter += 1
res.append(vertex)
for u in adj[vertex]:
indegree[u] -= 1
if indegree[u] == 0:
queue.append(u)
return res if counter == numCourses else []
单源最短路径
对于求图的单源最短路径,根据图的特性不同,有不同的算法解决。如果图是无权的,使用广度优先搜索即可,详情查看无权图的单源最短路径计算。而如果图是带权的,就需要使用其他算法了,而再根据图的带权情况,又有不同的算法。
在论述前,先要理解松弛 relax 操作。
对一条边的 (u, v) 的松弛过程为:首先测试一下是否可以对从 s 到 v 的最短路径进行改善。测试的方法是,将从结点 s 到结点 u 之间的最短路径距离加上结点 u 与 v 之间的边权重,并与当前的 s 到 v 的最短路径估计进行比较,如果前者更小,则对 v.d 和 v.p 进行更新。松弛步骤可能降低最短路径的估计值 v.d 并更新 v 的前驱属性 v.p 。
def relax(u, v, w):
if v.d > u.d + w(u, v):
v.d = u.d + w(u ,v)
v.p = u
Bellman-Ford
Bellman-Ford 算法通过对边进行松弛操作来渐进地降低从源结点 s 到每个结点 v 的最短路径的估计值 v.d ,直到该估计值与实际的最短路径权重 δ(s, v) 相同为止。
此外 Bellman-Ford 算法可以处理带负权值边的图。
Bellman-Ford 算法的伪代码如下:
procedure BellmanFord(list vertices, list edges, vertex source)
// 初始化图
for each vertex v in vertices:
if v is source then distance[v] := 0
else distance[v] := infinity
predecessor[v] := null
// 对每一个条边重复操作
for i from 1 to size(vertices)-1:
for each edge (u, v) with weight w in edges:
if distance[u] + w < distance[v]:
distance[v] := distance[u] + w
predecessor[v] := u
for each edge (u, v) with weight w in edges:
if distance[u] + w < distance[v]:
error "图包含负权重的圈"
Bellman-Ford 算法对所有边都进行松弛操作,就是遍历所有的边,计算出最短距离。在重复的计算中,已计算得到正确的距离的边的数量不断增加,直到所有边都计算得到了正确的路径。可以看出 Bellman-Ford 算法的效率是不高的,但是有很好的“兼容性”,适用于更多种类的输入。
SPFA
SPFA 算法,全称为最短路径快速算法 ,是对 Bellman-Ford 算法的优化。
SPFA 算法的基本思路与贝尔曼-福特算法相同,即每个节点都被用作用于松弛其相邻节点的备选节点。相较于贝尔曼-福特算法,SPFA 算法的提升在于它并不盲目尝试所有节点,而是维护一个备选节点队列,并且仅有节点被松弛后才会放入队列中。整个流程不断重复直至没有节点可以被松弛。
以下为 SPFA 算法的伪代码:
procedure Shortest-Path-Faster-Algorithm(G, s)
for each vertex v ≠ s in V(G)
d(v) := ∞
d(s) := 0
offer s into Q
while Q is not empty
u := poll Q
for each edge (u, v) in E(G)
if d(u) + w(u, v) < d(v) then
d(v) := d(u) + w(u, v)
if v is not in Q then
offer v into Q
其实 SPFA 还是广度优先搜索思想。SPFA 筛选入队的结点,其实是对生成的求解树进行了剪枝。
Dijkstra
维基上对 Dijkstra 算法的描述:
这个算法是通过为每个顶点 v 保留当前为止所找到的从 s 到 v 的最短路径来工作的。初始时,原点 s 的路径权重被赋为 0 (d[s] = 0)。若对于顶点 m 存在能直接到达的边(s,m),则把 d[m]设为 w(s, m),同时把所有其他(s 不能直接到达的)顶点的路径长度设为无穷大,即表示我们不知道任何通向这些顶点的路径(对于所有顶点的集合 V 中的任意顶点 v, 若 v 不为 s 和上述 m 之一, d[v] = ∞)。当算法结束时,d[v] 中存储的便是从 s 到 v 的最短路径,或者如果路径不存在的话是无穷大。
边的拓展是 Dijkstra 算法的基础操作:如果存在一条从 u 到 v 的边,那么从 s 到 v 的最短路径可以通过将边(u, v)添加到从 s 到 u 的路径尾部来拓展一条从 s 到 v 的路径。这条路径的长度是 d[u] + w(u, v)。如果这个值比当前已知的 d[v] 的值要小,我们可以用新值来替代当前 d[v] 中的值。拓展边的操作一直运行到所有的 d[v] 都代表从 s 到 v 的最短路径的长度值。此算法的组织令 d[u] 达到其最终值时,每条边(u, v)都只被拓展一次。
算法维护两个顶点集合 S 和 Q。集合 S 保留所有已知最小 d[v] 值的顶点 v ,而集合 Q 则保留其他所有顶点。集合 S 初始状态为空,而后每一步都有一个顶点从 Q 移动到 S。这个被选择的顶点是 Q 中拥有最小的 d[u] 值的顶点。当一个顶点 u 从 Q 中转移到了 S 中,算法对 u 的每条外接边 (u, v) 进行拓展。
伪代码如下:
function Dijkstra(G, w, s)
for each vertex v in V[G]
d[v] := infinity
previous[v] := undefined
d[s] := 0
S := empty set
Q := set of all vertices
while Q is not an empty set
u := Extract_Min(Q)
S.append(u)
for each edge outgoing from u as (u,v)
if d[v] > d[u] + w(u,v)
d[v] := d[u] + w(u,v)
previous[v] := u
要理解 Dijkstra 算法,我觉得首先要清楚这个算法的本质,即该算法是什么样的一个算法,这个问题参考与 戴克斯特拉算法(Dijkstra)的本质是贪心,还是动态规划?
我个人的对于 Dijkstra 的理解是一种特殊的搜索算法,是广度优先搜索思想的扩展。
广度优先搜索利用队列先进先出的特性,做到总是优先处理与被发现结点相连的结点,再进一步来说,总是优先处理与距离源结点更近的结点,以源结点为中心,由近到远遍历处理结点。
而 Dijkstra 算法也是这样的一种思想与做法,由于图是带权的,所以利用优先队列的特性去控制结点处理的先后顺序,本质上还是优先处理与距离源结点更近的结点,以源结点为中心,由近到远遍历处理结点。
二分图判断
顶点集 V 可分割为两个互不相交的子集,并且图中每条边依附的两个顶点都分属于这两个互不相交的子集,两个子集内的顶点不相邻。
而要判断图是否为二分图,可以使用染色法进行判断。
使用两种颜色分别对图的各个结点进行染色,被染为相同颜色的结点为同一个集合。使用搜索算法遍历结点,在遍历的同时进行染色,结点 v 染黄色,而后续通过搜索算法发现的与结点 v 相连的结点 u 染绿色,如果 u 已经被发现染色,就需要判断顶点 v 与 顶点 u 的颜色是否为一样,如果颜色一样,表示存在边只属于一个结点集合,不为二分图。
题目 Is Graph Bipartite? 为一道判断图是否为二分图的题目,以下为解题源码:
class Solution:
def isBipartite(self, graph: 'List[List[int]]') -> 'bool':
color = [0 for _ in range(len(graph))]
for root in range(len(graph)):
if color[root] != 0:
continue
queue = []
queue.append(root)
color[root] = 1
while queue:
v = queue.pop(0)
for u in graph[v]:
if color[u] == 0:
color[u] = -color[v]
queue.append(u)
else:
if color[v] == color[u]:
return False
return True