数据结构09-图
一、图的基本概念
1.什么是图
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
2.图的基本性质
- 线性表中我们把数据元素叫元素,树中将数据元素叫节点,在图中数据元素,我们称之为顶点(Vertex)。
- 线性表中可以没有元素,称为空表;树中可以没有节点。称为空树;图中不能没有顶点,可以没有边。
- 线性表中,相邻的数据元素之间具有线性关系;树中,相邻两层的节点具有层次关系;而图中,任意两点之间都可能有关系,顶点之间的逻辑关系用边表示,边集可以是空的。
3.无向图
无向边:若顶点Vi到Vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶(ViVj)来表示。
无向图(Undirected graphs)是任意两个顶点之间的边都是无向边的图。
无向完全图是任意两个顶点之间都存在边的无向图。
4.有向图
有向边:若顶点Vi到Vj之间的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶<Vi, Vj>来表示,Vi称为弧尾(Tail),。Vi称为弧头(Head)。
有向图(Directed graphs)是任意两个顶点之间的边都是有向边的图。
有向完全图是任意两个顶点之间都存在方向相反的两条弧的有向图。
5.图的权
有些图的边或弧具有与它相关的数字,这些数字叫做权。
6.连通图
在一个无向图 G 中,若从顶点i到顶点j有路径相连(当然从j到i也一定有路径),则称i和j是连通的。如果 G 是有向图,那么连接i和j的路径中所有的边都必须同向。
连通图:图中任意两点都是连通的图。
连通分量:无向图 G的一个极大连通子图称为 G的一个连通分量。连通图只有一个连通分量,即其自身;非连通的无向图有多个连通分量。
强连通图:有向图 G(V,E) 中,若对于V中任意两个不同的顶点 x和 y,都存在从x到 y以及从 y到 x的路径,则称 G是强连通图。
强连通分量:强连通图只有一个强连通分量,即是其自身;非强连通的有向图有多个强连分量。
7.度
无向图顶点的边数叫度,有向图顶点的边数叫出度和入度。
二、图的数据存储结构
由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系。也就是说,图不可能用简单的顺序存储结构来表示。
图有两种存储结构:邻接矩阵和邻接表。
1.邻接矩阵
考虑到图是由顶点和边组成的,用一个结构表示比较困难,所以用两个结构来分别存储。顶点不分大小、主次,所以用一个一维数组来存储。而边是顶点与顶点之间的关系,一位数组搞不定,所有用二维数组。于是我们的邻接矩阵方案就诞生了。
图的邻接矩阵(Adjacency Matrix)用两个数组来表示图。一个一位数组存储顶点,一个二维数组(称为邻接矩阵)存储边。这个二维数组是一个对称矩阵。
带权邻接矩阵是图的边带有权重的邻接矩阵。
优点:实现简单,可以直接查询任意两节点间是否存在边,和边的权值
缺点:遍历效率低;对于边数相对顶点较少的图,这种结构存在对存储空间的极大浪费。
2.邻接表
邻接表用数组和链表表示,数组存放顶点,每个顶点又是一个链表,用于存放顶点的所有邻接顶点。在有向图中,这个链表存放的是相邻并且有方向的顶点;在无向图中,这个链表存放的是所有相邻的顶点。
出边表:链表中存放正向相邻顶点的邻接表。
称逆邻接表:链表中存放逆向相邻顶点的邻接表。。
带权邻接表:链表中存放相邻顶点和相邻顶点之间的边的权值的邻接表。
优点:复杂度低
缺点:无法直接判断两点间是否存在边
三、图的遍历
图的遍历和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且每一个顶点仅访问一次,这一过程就叫做图的遍历(Traversing Graph)。
1.深度优先遍历
基本思想
假设给定图G的初态是所有顶点均未曾访问过。在G中任选一顶点v为初始出发点(源点),则深度优先遍历可定义如下:
- 首先访问出发点v,并将其标记为已访问过;
- 然后依次从v出发搜索v的每个邻接点w。若w未曾访问过,则以w为新的出发点继续进行深度优先遍历,直至图中所有和源点v有路径相通的顶点均已被访问为止。
- 若此时图中仍有未访问的顶点,则另选一个尚未访问的顶点作为新的源点重复上述过程,直至图中所有顶点均已被访问为止。
图的深度优先遍历也叫深度优先搜索(Depth First Search),类似于树的前序遍历。采用的搜索方法的特点是尽可能先对纵深方向进行搜索。
应用:最大路径。
代码实现
//深度优先遍历
public void dfsErgodic() {
if (Tool.isEmpty(vertices)) {
return;
}
boolean[] visit = new boolean[vertices.length];
for (int i = 0; i < vertices.length; i++) {
dfs(visit, i);
}
}
/**
* @param visit 表示已经访问过的顶点
* @param index 对应顶点的下标
*/
public void dfs(boolean[] visit, int index) {
if (visit[index] || Tool.isEmpty(matrix) || Tool.isEmpty(matrix[index])) {
return;
}
visit[index] = true;
ToolShow.log(vertices[index].toString());
//邻接点
int[] mat = matrix[index];
for (int i = 0; i < mat.length; i++) {
//权值为0代表自己,权值为M代表不可达
if (mat[i] > 0 && mat[i] < M) {
//优先访问第一个邻接点
dfs(visit, i);
}
}
}
2.广度优先遍历
基本思想
广度优先遍历是连通图的一种遍历策略。因为它的思想是从一个顶点V0开始,辐射状地优先遍历其周围较广的区域,故得名。
遍历过程:
- 从图中某个顶点V0出发,并访问此顶点;
- 从V0出发,访问V0的各个未曾访问的邻接点W1,W2,…,Wk;然后,依次从W1,W2,…,Wk出发访问各自未被访问的邻接点;
- 重复步骤2,直到全部顶点都被访问为止。
应用:广度优先生成树、最短路径。
代码实现
// 广度优先遍历
public void bfsErgodic() {
if (Tool.isEmpty(vertices)) {
return;
}
boolean[] visit = new boolean[vertices.length];
for (int i = 0; i < vertices.length; i++) {
bfs(visit, i);
}
}
/**
* @param visit 表示已经访问过的顶点
* @param index :对应顶点的下标
*/
public void bfs(boolean[] visit, int index) {
if (visit[index] || Tool.isEmpty(matrix) || Tool.isEmpty(matrix[index])) {
return;
}
visit[index] = true;
ToolShow.log(vertices[index].toString());
//邻接点
int[] mat = matrix[index];
//已访问过的邻接点
List<Integer> visitedE = new ArrayList<>();
//先访问所有的邻接点
for (int i = 0; i < mat.length; i++) {
//不能重复访问
if (!visit[i] && mat[i] > 0 && mat[i] < M) {
visit[i] = true;
ToolShow.log(vertices[i].toString());
visitedE.add(i);
}
}
//再以已经访问过的邻接点为起点,开始访问
for (Integer m : visitedE) {
dfs(visit, m);
}
}
四、最小生成树
1.基本概念
树(Tree):不存在回路的无向连通图。
生成树(Spanning Tree):无向连通图G的一个子图如果是一颗包含G所有顶点的树,则该子图称为G的生成树。
生成树的权:无向连通图的生成树的各边的权值总和。
最小生成树(Minimum Spanning Tree ,MST):权最小的生成树。最小生成树也叫最小代价树(Minimum-cost Spanning Tree)。
最小生成树算法:Prim、Kruskal。
应用:城市光钎路径等。
2.Prim算法
基本思想
Prim算法(普里姆算法)是一种最小生成树算法。Prim算法从任意一个顶点开始,每次选择一个与当前顶点集最近的一个顶点,并将两顶点之间的边加入到树中。
Prim算法在找当前最近顶点时使用到了贪婪算法。
算法描述:
- 在一个加权连通图中,顶点集合V,边集合为E;
- 任意选出一个点作为初始顶点,标记为visit,计算所有与之相连接的点的距离,选择距离最短的,标记visit;
- 在剩下的点中,计算与已标记visit点距离最小的点,标记visit,证明加入了最小生成树;
- 重复3,直到所有点都被标记为visit。
代码实现
public List<Edge<E>> prim(E v) {
int size = vertices == null ? 0 : vertices.length;
if (size < 1) {
return null;
}
List<Edge<E>> result = new ArrayList<>();
//已标记的点
List<Integer> visit = new ArrayList<>();
visit.add(getIndex(v));
for (int m = 0; m < size; m++) {
int start = -1;
int end = -1;
int weight = M;
//找到未访问的点中,距离当前最小生成树距离最小的点
for (Integer n : visit) {
//邻接点
int[] mat = matrix[n];
//找到最小邻接点的下标
int min = getMin(mat, visit);
if (min != -1 && mat[min] > 0 && mat[min] < weight) {
weight = mat[min];
start = n;
end = min;
}
}
if (start > -1 && end > -1) {
Edge<E> e = new Edge<>(vertices[start], vertices[end], weight);
result.add(e);
visit.add(end);
}
}
return result;
}
//获取数组的最小权值的下标
public int getMin(int[] arr, List<Integer> visit) {
int index = -1;
if (Tool.isEmpty(arr)) {
return index;
}
int weight = M;
for (int i = 0; i < arr.length; i++) {
if (visit.contains(i)) {
continue;
}
int w = arr[i];
if (w > 0 && w < weight) {
index = i;
weight = w;
}
}
return index;
}
3.Kruskal算法
基本思想
Kruskal算法(克鲁斯卡尔算法)是另一个计算最小生成树的算法,其算法原理如下:
- 首先,将所有的边排序,并创建一个空的顶点集合;
- 然后,按照权值的升序来选择边。如果起点和终点被相同的集合包含,就跳过。否则,将这条边插入最小生成树中。
- 然后更新顶点集合:如果所有的集合都不包含这条边的顶点,就新建一个集合,并将他的顶点添加进去;如果起点只被一个集合包含,那就把终点添加到这个集合;如果终点只被一个集合包含,那就把起点添加到这个集合;如果起点和终点分别被不同的集合包含,那就把这两个集合合并;
- 重复这个过程直到所有的边都探查过。
代码实现
public List<Edge<E>> kruskal() {
if (Tool.isEmpty(edges)) {
return null;
}
int size = vertices.length;
//已取出的线的顶点的集合
List<List<E>> visitList = new ArrayList<>();
List<Edge<E>> result = new ArrayList<>();
for (int i = 0; i < edges.length; i++) {
Edge<E> e = edges[i];
//包含e的起点的集合
List<E> start = null;
//包含e的终点的集合
List<E> end = null;
for (List<E> eList : visitList) {
if (Tool.isEmpty(eList)) {
break;
}
if (eList.contains(e.start) && !eList.contains(e.end)) {
start = eList;
} else if (!eList.contains(e.start) && eList.contains(e.end)) {
end = eList;
} else if (eList.contains(e.start) && eList.contains(e.end)) {
start = eList;
end = eList;
}
}
//如果所有的集合都不包含这条边的顶点,就新建一个集合,并将他的顶点添加进去
if (start == null && end == null) {
List<E> list = new ArrayList<>();
list.add(e.start);
list.add(e.end);
visitList.add(list);
//如果起点只被一个集合包含,那就把终点添加到这个集合
} else if (start != null && end == null) {
start.add(e.end);
//如果终点只被一个集合包含,那就把起点添加到这个集合
} else if (start == null && end != null) {
end.add(e.start);
//如果起点和终点分别被不同的集合包含,那就把这两个集合合并
} else if (start != end) {
start.addAll(end);
visitList.remove(end);
//如果起点和终点被相同的集合包含,就跳过
} else {
break;
}
result.add(e);
}
return result;
}
五、最短路径算法
从某顶点出发,沿图的边到达另一顶点所经过的路径中,各边上权值之和最小的一条路径叫做最短路径。
解决最短路的问题有以下算法,Dijkstra算法,Bellman-Ford算法,Floyd算法和SPFA算法等
这里我们只说明Dijkstra算法(迪杰斯特拉算法)。
1.Dijkstra算法的基本思想
迪杰斯特拉(Dijkstra)算法是典型最短路径算法,用于计算一个节点到其他节点的最短路径。
它的主要特点是以起始点为中心向外层层扩展(广度优先搜索思想),直到扩展到终点为止。
操作步骤:
- 引进两个集合S和U,S的作用是记录已求出最短路径的顶点,而U中元素的下标表示顶点,U中元素的值表示该顶点到起点s的距离;
- 初始时,S只包含起点s;
- 从U中选出距离最短的顶点k,并将顶点k加入到S中;
- 利用k更新U的值。如果k与o直连,则取(s,o)的距离与(s,k)+(k,o)的距离的最小值作为s与o的距离;
- 重复步骤3和4,直到遍历完所有顶点。
2.Dijkstra算法的证明
Dijkstra算法每次从更新后的U中,挑选权值最小的顶点k,然后把k的值当作起点s与顶点k的最短距离。然后用k更新其他未确定最短距离的顶点的值。下面证明它的正确性:
我们把所有与s直接相连顶点叫s的直连点,所有与s不直接相连顶点叫s的非直连点。
最初时,U中的值为s与直接点的权值,可以确定权值最小的顶点k的最短路径就是这个最小权值D,因为通过任意非直接点来连接s,都必须经过至少一个直连点。而s与非直连点的路径=s与直连点的路径+直连点与非直连点的路径,这个值肯定大于D,即D是s与其他点的距离的最小值,那么s与k的距离肯定不会小于D,即s与k的最短路径为D。
然后,把k加入S,再利用k更新U的值。如果k与o直连,则取(s,o)的距离与(s,k)+(k,o)的距离的最小值作为s与o的距离。
然后,在找U中权值最小的顶点k1,这个时候U中与s的所有可达点就相当于s的直连点。按照上面的推论,s与k1的最短路径即为U中的最小权值。
重复以上步骤,即可找出所有顶点与s的最短距离。
3.Dijkstra算法的实现
public int[] dijkstra(E e) {
if (Tool.isEmpty(vertices)) {
return null;
}
int size = vertices.length;
//e的下标
int index = getIndex(e);
//已求出最短路径的顶点
List<Integer> S = new ArrayList<>();
S.add(index);
//e与其他顶点的最短距离
int[] U = Arrays.copyOf(matrix[index], size);
for (int k = 0; k < size; k++) {
//找出最短路径的顶点
int update = getMin(U, S);
if (update == -1) {
break;
}
S.add(update);
//更新U
for (int i = 0; i < U.length; i++) {
int weight = U[i];
int newWeight = U[update] + matrix[update][i];
if (weight > 0 && !S.contains(i) && newWeight < weight) {
U[i] = newWeight;
}
}
}
return U;
}
最后
数据结构与算法专题:https://www.jianshu.com/nb/25128590
喜欢请点赞,谢谢!