数据结构 - 图
转载注明出处
原文地址 : http://47.99.144.106/archives/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84-%E5%9B%BE
图
概念
图(graph)是由一些点(vertex)和这些点之间的连线(edge)所组成的;其中,点通常称为 顶点(vertex) ,而点到点之间的连线通常称之为 边或者弧(edge) 。通常记为G=(V,E)。
权重:当边带有数字标签时,可以将这些数字称为 权重 ,并且说这个图是一个 加权图
路径:一个顶点到另一个顶点的边的序列
连通的图:图中的每一个顶点到其他的每一个顶点都有一条路径
完全的图:从每一个顶点到其他的每一个顶点都有一条边
分类
根据边是否有方向,图又分为 有向图 和 无向图
性质
N个节点的完全有向图边的数目:N*(N-1)
N个节点的完全无向图边的数目:N*(N-1)/ 2
表示方式
图有 邻接矩阵 和 邻接链表 两种表示方式
邻接矩阵
-
如果图中有顶点 i 到顶点 j 的一条边,则G[i] [j]= 1
-
如果边有权重,权重值可以占用矩阵单元格,没有边的单元格要用不在允许的权重值范围之内的一个值来表示
邻接表
- 图的邻接表是N个链表的一个数组
- 只有从 i 到 j 有一条边时,第i个链表才包含了顶点j的一个节点
- 当边有权重时,权重可以作为节点的一个数据字段
两种表示的分析
相邻矩阵 | 邻接表 | |
---|---|---|
判断两个给定点之间是否有一条边 | O(1) | 与列表长度成相信关系 |
找出一个给定点的所有相邻节点 | O(N) | 取决于给定点的列表长度 |
内存 | O(N^2) | N个指针的一个数组 |
使用场景 | 稠密图 | 稀疏图 |
遍历
深度优先遍历(depth-first traversal)
图的 深度优先遍历类似于树的先序遍历, 采用的搜索方法的特点是尽可能先对纵深方向进行搜索。
假设给定图G的初态是所有顶点均未曾访问过
首先访问出发点v,并将其标记为已访问过;
然后依次从v出发搜索v的每个邻接点w。若w未曾访问过,则以w为新的出发点继续进行深度优先遍历,直至图中所有和源点v有路径相通的顶点(亦称为从源点可达的顶点)均已被访问为止。
若此时图中仍有未访问的顶点,则另选一个尚未访问的顶点作为新的源点重复上述过程,直至图中所有顶点均已被访问为止。【1】
下面用一个具体的例子来说明:
Tip:使用VisuAlgo来可视化观察整个过程
- 首先标记点0,然后按字典序寻找未标记的相邻点进行遍历(点1)。
- 标记点1,按字典序寻找未标记的相邻点继续遍历(点2)。
- 标记点7,由于7没有相邻点,回溯点0。
- 标记点3,同样步骤标记点5和6
- 尝试标记点6的相邻点5,由于5已经标记过了,回溯到点0。
- 标记点4。
- 尝试标记点1,由于点1已经标记过了,回溯到点0,结束遍历。
实现
def dfs(graph, start):
"""
:param graph: 邻接表存储的图
:param start: 开始遍历的源节点
:return: dfs结果序列
"""
visited = [] # 存放访问过的节点的列表
stack = [start] # 构造一个堆栈
while stack: # 堆栈空时结束
current = stack.pop() # 堆顶出栈
if current not in visited: # 判断当前结点是否被访问过
visited.append(current) # 如果没有访问过,则将其加入访问列表
for next_adj in graph[current]: # 遍历当前结点的下一级
if next_adj not in visited: # 没有访问过的全部入栈
stack.append(next_adj)
return visited
广度优先遍历(breadth-first traversal)
广度优先搜索以队列作为算法中的集合,类似于树的层序遍历。
仍然以上面的例子来说明:
- 首先标记点0,按顺序将1,3,4加入队列
- 取队头的1,将2加入队列
- 取队头的3,将5和6加入队列
- 取队头的4,尝试将1加入队列,但1已经被访问过了。
- 取队头的2,将7加入队列
- 重复以上步骤至结束
实现
def bfs(graph, start):
"""
:param graph: 邻接链表存储的图
:param start: 开始遍历的源节点
:return: bfs结果序列
"""
visited = [] # 存放访问过的节点的列表
queue = Queue() # 构造一个队列
queue.put(start)
while not queue.empty(): # 队列空时结束
current = queue.get() # 取队首元素
if current not in visited: # 判断当前结点是否被访问过
visited.append(current) # 如果没有访问过,则将其加入访问列表
for next_adj in graph[current]: # 遍历当前结点的下一级
if next_adj not in visited: # 没有访问过的全部入队
queue.put(next_adj)
return visited
最小生成树
当遍历图结构时会隐式地形成一棵树,它的根节点就是遍历开始的顶点。当图中的边有权重时,可以将生成树的所有边的权重加和,以试图找出这个和最小的一颗生成树。
- 由于树的结构特点,最小生成树不能成环
- 对于N个顶点的连通图,生成树只能有N-1条边,这N-1条边连通着N个顶点。
Kruskal算法
由Joseph Kruskal于1956年提出,其算法思路为:
-
将连通图中的所有边取出,按权重从小到大排序
-
将边按从小到大的顺序填回图中,条件是:
如果这个边不会与之前选择的所有边组成回路,就可以作为最小生成树的一部分;反之,舍去。
-
直到具有 n 个顶点的连通网筛选出来 n-1 条边为止
Prim算法
由 Robert C. Prim 于1957年提出,其算法简单描述为:
-
对一个加权连通图G=(V,E),初始化Vnew={u}, u为图中的任一节点,Enew={},空集合
-
重复下列操作,直到Vnew = V:
a.在集合E中选取权值最小的边<u, v>,其中u为集合Vnew中的元素,而v不在Vnew集合当中,并且v∈V(如果存在有多条满足前述条件即具有相同权值的边,则可任意选取其中之一);
b.将v加入集合Vnew中,将<u, v>边加入集合Enew中;
单源点最短路径问题
Dijkstra算法
迪杰斯特拉算法(Dijkstra) 是计算一个顶点到其余各顶点最短路径的算法。
算法思想为:
- 声明一个集合s来存储已经确定最短路径的顶点 s = {},以及一个数组 dis 来存储各顶点的最短路径
- 初始化 s = {a}(起始点),dis = [0, ∞,∞,∞,∞,∞,∞,∞ ]
- 找到一条边就能到达起始点的顶点,将最短的那个顶点加入 s 中,更新对应的 dis
- 选择一条边就能到达集合中点的顶点,将(边长+直达顶点对应的 dis )最短的那个顶点加入 s ,更新该顶点的 dis
- 重复以上步骤直到 s 包含图中所有的顶点
时间复杂度:O(n^2);
多源点最短路径问题
单源点最短路径是求一个源点到图中其他点的距离,结果用一个数组表示。而多源点最短路径是求图中所有顶点到其他点的最短路径,结果用一个矩阵来表示
计算多源点最短路径可以通过对n个顶点都进行一次dijkstra算法来实现,时间复杂度为O(n^3),也可以通过Floyd算法实现
Floyd算法
对于上面这张图,我们可以用一个4 * 4 的矩阵 e 来存储其直达路径,e[i][j]来表示 i 到 j 的距离,如 e[4][3] = 12,如果两个点无法直达,则用 ∞ 来表示。
如果想让任意两点间的路径变短,只有通过引入其他顶点来 中转 ,例如4到3,可以引入顶点1,这样两点间的距离就缩短成了5+6=11,中转点也有可能是多个顶点,如引入顶点1和2,距离就缩短成了5+2+3=10。
我们先假设两点之间不允许经过第三个顶点,那么各个顶点间的最短路径就是直达路径,如下
现在放宽限制,允许通过1号顶点来 中转 ,那么如何求出两点间的最短路径呢?很简单,我们只需要判断e[i][1] + e[1][j] 是否小于 e[i][j],小于更新,否则不变,处理后路径变化如下:
接下来继续放宽条件,允许通过1和2两个顶点来中转,那么我们需要在允许1中转条件下得到的最短路径矩阵(也就是上面这张图)的基础上,判断e[i][2] + e[2][j] 是否小于 e[i][j],结果如下:
以同样的步骤分别继续开放3和4顶点作为中转,结果如下:
Tip:Floyd算法不能解决带有"负权回路"的图,因为带有负权回路的图没有最短路,如下图,从点1到3,每次经过1->2->3->1->2->…->1,每绕一次圈最短路就减1,永远找不到最短路。
参考
【1】 深度优先遍历和广度优先遍历