文章目录
图是一种比线性表和树更为复杂的数据结构。在线性表中,数据元素之间仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继;在树形结构中,数据元素之间有着明显的层次关系,并且每一层中的数据元素可能和下一层中的多个元素(即其孩子结点)相关,但只能和上一层中一个元素(即其双亲结点)相关; 而在图结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
一、图的基本概念
在计算机科学中的图是由点和边构成的。
图1:图的示意图
1、图的定义
图(Graph) G由两个集合V和E组成,记为G=(V,E) , 其中V是顶点的有穷非空集合,E是V中顶点偶对的有穷集合,这些顶点偶对称为边。V(G)和E(G)通常分别表示图G的顶点集合和边集合,E(G)可以为空集。若 E(G)为空,则图G只有顶点而没有边。
对于图G ,若边集E(G)为有向边的集合,则称该图为有向图;若边集E(G)为无向边的集合,则称该图为无向图。
图2:无向图(a)和有向图(b)
在有向图中,顶点对<x, y>是有序的,它称为从顶点 x到顶点y的一条有向边。 因此<x,y>与<y, x>是不同的两条边。 顶点对用一对尖括号括起来,x是有向边的始点,y是有向边的终点。<x, y>也称作一条弧,则 x为弧尾, y为弧头。
在无向图中,顶点对<x, y>是无序的,它称为从顶点 x与顶点y相关联的一条边。这条边没有特定的方向,(x,y) 和 (y,x)是同一条边。为了区别于有向图,无向图的一对顶点用括号括起来。
2、图的基本术语
用n表示图中顶点数目,用e表示边的数目, 来看看图结构中的一些基本术语。
- 子图:假设有两个图 G = (V, E)和 G’= (V’, E’), 如果V’己V 且 E’ ⊆ \subseteq ⊆E, 则称 G’为 G 的子图。例如, 图 3 所示为图 2 中 G 1 和 G2 子图的一些例子。
图3:子图示例
- 无向完全图和有向完全图:对千无向图, 若具有 n(n- 1)/2 条边,则称为无向完全图。对于有向图, 若具有 n(n- l)条弧,则称为有向完全图。
- 稀疏图和稠密图:有很少条边或弧(如 e<nlog2n) 的图称为稀疏图, 反之称为稠密图。
- 权和网:在实际应用中,每条边可以标上具有某种含义的数值,该数值称为该边上的权。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网。
- 邻接点:对于 无向图 G, 如果图的边 (v, v’) ∈ \in ∈E, 则称顶点 v 和 v’互为邻接点, 即 v 和 v’相邻接。边 (v, v’)依附于顶点 v 和 v’, 或者说边 (v, v’)与顶点 v 和 v’相关联。
- 度、入度和出度:顶知的度是指和v 相关联的边的数目,记为 TD(v) 。例如,图2 (b) 中G2的顶点 V3 的度是3。对于有向图,顶点v的度分为入度和出度。入度是以顶点v为头的弧的数目,记为 ID(v); 出度是以顶点 v 为尾的弧的数目,记为OD(v)。顶点 v 的度为 TD(v) = ID(v) + OD(可。例如,图2中 G1 的顶点v1的入度 ID(v1)=1, 出度 OD(v1)=2, 度TD(v1)= ID(v1) + OD(v1) =3。一般地,如果顶点 Vi 的度记为 TD(vi),那么一个有n个顶点,e条边的图,满足如下关系:
- 路径和路径长度:在无向图 G 中,从 顶点 v 到顶点 v’的 路径是一个顶点序列 (v = vi,0,Vi, 1,…, i;, m= v’), 其中 (vi,j-1, vi,j) ∈ \in ∈E, 其中1 ≤ \leq ≤j ≤ \leq ≤m。 如果 G 是有向图, 则路径也是有向的,顶点序列应满 足 <v;,1-1, vi,j)> ∈ \in ∈E, 其中1 ≤ \leq ≤j ≤ \leq ≤m。 路径长度是一条路径上经过的边或弧的数目。
- 回路或环:第一个顶点和最后一个顶点相同的路径称为回路或环。
- 简单路径、 简单回路或简单环:序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外, 其余顶点不重复出现的回路,称为简单回路或简单环。
- 连通、连通图和连通分量:在无向图 G 中,如果从顶点 v 到顶点 v’有路径,则称 v 和 v’是连通的。如果对于图中任意两个顶点 Vi、 Vj ∈ \in ∈V, Vi 和 Vj 都是连通的,则称 G 是连通图。图 2
(b)中的 G2 就是一个连通图,而图 4 (a) 中的 G3 则是非连通 图,但 G3 有 3个连通分量,如图
图4:无向图及其连通分量
- 强连通图和强连通分量:在有向图 G 中,如果对于每一对 Vi, Vj ∈ \in ∈V,Vi ≠ \not= =Vj, 从 Vi到 Vj和
从 Vj 到Vi都存在路径,则称G是强连通图。有向图中的极大强连通子图称作有向图的强连通分量。例如图2 中的G1 不是强连通图,但它有两个强连通分量,如图5所示。
图5:G1 的两个强连通分量
- 连通图的生成树:一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的 n-1 条边,这样的连通子图称为连通图的生成树。图6所示为G3 中最大连通分量的一棵生成树。如果在一棵生成树上添加一条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径。
图6:G3的最大连通分量的一棵生成树
- 有向树和生成森林:有一个顶点的入度为 0, 其余顶点的入度均为 l1的有向图称为有向树。 一个有向图的生成森林是由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。 图7所示为其一例。
图7:一个有向图及其生成森林
二、图的存储结构
图的存储结构相较线性表与树来说就更加复杂。
图的存储结构比较常见的有两种,邻接矩阵和邻接表。
1、邻接矩阵
具体地,若图 G 中包含 n 个顶点,我们就使用一个 n×n 的方阵 A,并使每一顶点都分别对应于某一行(列)。既然图所描述的是这些顶点各自对应的元素之间的二元关系,故可以很自然地将任意一对元素 u 和 v 之间可能存在二元关系与矩阵 A 中对应的单元 A[u, v]对应起来: 1 或 true 表示存在关系, 0 或 false 表示不存在关系。这一矩阵中的各个单元分别描述了一对元素之间可能存在的邻接关系,故此得名。
图8:邻接矩阵存储示意图
(a)是无向图, (b)是有向图。无向图的邻接矩阵,是一个对称矩阵。在图中所示的矩阵,a[i][j] 值都为1,如果是带权的图,我们可以将其设置为权值。
这一表示形式也可以推广至带权图,具体方法是,将每条边的权重记录在该边对应得矩阵单元中。
需要注意的是:
- (1) 邻接矩阵表示法对于以图的顶点为主的运算比较适用;
- (2) 除完全图外, 其他图的邻接矩阵有许多零元素, 特别是当 n 值较大, 而边数相对完全图的边又少得多时, 则此矩阵称为“ 稀疏矩阵” , 比较浪费存储空间。
图的邻接矩阵表示方法简单实现如下:
/** * @Author 三分恶 * @Date 2020/11/28 * @Description 图的邻接矩阵存储实现 */ public class AMWGraph { private ArrayList vertexList;//存储点的链表 private int[][] edges;//邻接矩阵,用来存储边 private int numOfEdges;//边的数目 public AMWGraph(int n) { //初始化矩阵,一维数组,和边的数目 edges=new int[n][n]; vertexList=new ArrayList(n); numOfEdges=0; } //得到结点的个数 public int getNumOfVertex() { return vertexList.size(); } //得到边的数目 public int getNumOfEdges() { return numOfEdges; } //返回结点i的数据 public Object getValueByIndex(int i) { return vertexList.get(i); } //返回v1,v2的权值 public int getWeight(int v1,int v2) { return edges[v1][v2]; } //插入结点 public void insertVertex(Object vertex) { vertexList.add(vertexList.size(),vertex); } //插入结点 public void insertEdge(int v1,int v2,int weight) { edges[v1][v2]=weight; numOfEdges++; } //删除结点 public void deleteEdge(int v1,int v2) { edges[v1][v2]=0; numOfEdges--; } //得到第一个邻接结点的下标 public int getFirstNeighbor(int index) { for(int j=0;j<vertexList.size();j++) { if (edges[index][j]>0) { return j; } } return -1; } //根据前一个邻接结点的下标来取得下一个邻接结点 public int getNextNeighbor(int v1,int v2) { for (int j=v2+1;j<vertexList.size();j++) { if (edges[v1][j]>0) { return j; } } return -1; } }
2、邻接表
邻接矩阵虽然比较直观,但是空间利用率是上并不理想。其中大量的单元所对应的边有可能并未在图中出现,这也是静态向量结构普遍的不足。既然如此,我们为什么不将向量改为列表呢?
邻接表是图的一种链接存储结构。 邻接表表示法只关心存在的边,将顶点的邻接边用列表表示。
图9:邻接表存储示意图
我们来看一下具体的实现。
2.1、有向图接口定义
这是有向图的抽象接口定义。
/** * @Author 三分恶 * @Date 2020/11/28 * @Description 有向图接口 */ public interface IDirectGraph<V> { /** * 新增顶点 * * @param v 顶点 * @since 0.0.2 */ void addVertex(final V v); /** * 删除顶点 * * @param v 顶点 * @return 是否删除成功 * @since 0.0.2 */ boolean removeVertex(final V v); /** * 获取顶点 * * @param index 下标 * @return 返回顶点信息 * @since 0.0.2 */ V getVertex(final int index); /** * 新增边 * * @param edge 边 * @since 0.0.2 */ void addEdge(final Edge<V> edge); /** * 移除边 * * @param edge 边信息 * @since 0.0.2 */ boolean removeEdge(final Edge<V> edge); /** * 获取边信息 * * @param from 开始节点 * @param to 结束节点 * @since 0.0.2 */ Edge<V> getEdge(final int from, final int to); }
2.2、边的实现
这是有向图的边的实现:
/** * @Author 三分恶 * @Date 2020/11/28 * @Description 边 */ public class Edge<V> { /** * 开始节点 * @since 0.0.2 */ private V from; /** * 结束节点 * @since 0.0.2 */ private V to; /** * 权重 * @since 0.0.2 */ private double weight; public Edge(V from, V to) { this.from = from; this.to = to; } public V getFrom() { return from; } public void setFrom(V from) { this.from = from; } public V getTo() { return to; } public void setTo(V to) { this.to = to; } public double getWeight() { return weight; } public void setWeight(double weight) { this.weight = weight; } @Override public String toString() { return "Edge{" + "from=" + from + ", to=" + to + ", weight=" + weight + '}'; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Edge<?> edge = (Edge<?>) o; return Double.compare(edge.weight, weight) == 0 && to.equals(edge.to) && from.equals(edge.from); } @Override public int hashCode() { return hashCode(); } }
2.3、有向图节点
这里我们不再单独做顶点的实现,所以 节点=顶点+边。
/** * @Author 三分恶 * @Date 2020/11/28 * @Description */ public class GraphNode<V> { /** * 顶点信息 * @since 0.0.2 */ private V vertex; /** * 以此顶点为起点的边的集合,是一个列表,列表的每一项是一条边 * * (1)使用集合,避免重复 */ private Set<Edge<V>> edgeSet; /** * 初始化一个节点 * @param vertex 顶点 */ public GraphNode(V vertex) { this.vertex = vertex; this.edgeSet = new HashSet<Edge<V>>(); } /** * 新增一条边 * @param edge 边 */ public void add(final Edge<V> edge) { edgeSet.add(edge); } /** * 获取目标边 * @param to 目标边 * @return 边 * @since 0.0.2 */ public Edge<V> get(final V to) { for(Edge<V> edge : edgeSet) { V dest = edge.getTo(); if(dest.equals(to)) { return edge; } } return null; } /** * 获取目标边 * @param to 目标边 * @return 边 * @since 0.0.2 */ public Edge<V> remove(final V to) { Iterator<Edge<V>> edgeIterable = edgeSet.iterator(); while (edgeIterable.hasNext()) { Edge<V> next = edgeIterable.next(); if(to.equals(next.getTo())) { edgeIterable.remove(); return next; } } return null; } public V getVertex() { return vertex; } public Set<Edge<V>> getEdgeSet() { return edgeSet; } @Override public String toString() { return "GraphNode{" + "vertex=" + vertex + ", edgeSet=" + edgeSet + '}'; } }
2.4、有向图具体实现
接下来是有向图的邻接表表示具体实现。
/** * @Author 三分恶 * @Date 2020/11/28 * @Description */ public class ListDirectGraph<V> implements IDirectGraph<V> { /** * 节点链表 * * @since 0.0.2 */ private List<GraphNode<V>> nodeList; /** * 初始化有向图 * * @since 0.0.2 */ public ListDirectGraph() { this.nodeList = new ArrayList<GraphNode<V>>(); } public void addVertex(V v) { GraphNode<V> node = new GraphNode<V>(v); // 直接加入到集合中 this.nodeList.add(node); } public boolean removeVertex(V v) { //1. 移除一个顶点 //2. 所有和这个顶点关联的边也要被移除 Iterator<GraphNode<V>> iterator = nodeList.iterator(); while (iterator.hasNext()) { GraphNode<V> graphNode = iterator.next(); if (v.equals(graphNode.getVertex())) { iterator.remove(); } } return true; } public V getVertex(int index) { return nodeList.get(index).getVertex(); } public void addEdge(Edge<V> edge) { //1. 新增一条边,直接遍历列表。 // 如果存在这条的起始节点,则将这条边加入。 // 如果不存在,则直接报错即可。 for (GraphNode<V> graphNode : nodeList) { V from = edge.getFrom(); V vertex = graphNode.getVertex(); // 起始节点在开头 if (from.equals(vertex)) { graphNode.getEdgeSet().add(edge); } } } public boolean removeEdge(Edge<V> edge) { // 直接从列表中对应的节点,移除即可 GraphNode<V> node = getGraphNode(edge); if (null != node) { // 移除目标为 to 的边 node.remove(edge.getTo()); } return true; } public Edge<V> getEdge(int from, int to) { // 获取开始和结束的顶点 V toVertex = getVertex(from); // 获取节点 GraphNode<V> fromNode = nodeList.get(from); // 获取对应结束顶点的边 return fromNode.get(toVertex); } /** * 获取图节点 * * @param edge 边 * @return 图节点 */ private GraphNode<V> getGraphNode(final Edge<V> edge) { for (GraphNode<V> node : nodeList) { final V from = edge.getFrom(); if (node.getVertex().equals(from)) { return node; } } return null; } /** * 获取对应的图节点 * * @param vertex 顶点 * @return 图节点 * @since 0.0.2 */ private GraphNode<V> getGraphNode(final V vertex) { for (GraphNode<V> node : nodeList) { if (vertex.equals(node.getVertex())) { return node; } } return null; } }
三、图的遍历
和树的遍历类似,图的遍历也是从图中某一顶点出发,按照某种方法对图中所有顶点访问且仅访问一次。然而, 图的遍历要比树的遍历复杂得多。 因为图的任一顶点都可能和其余的顶点相邻接。 所以在访问了某个顶点之后, 可能沿着某条路径搜索之后, 又回到该顶点上。
根据搜索路径的方向, 通常有两条遍历图的路径:深度优先遍历和广度优先遍历。 它们对无向图和有向图都适用。
1、深度优先遍历
深度优先(DepthFirst Search, DFS)遍历类似千树的先序遍历,是树的先序遍历的推广。
对于一个连通图,深度优先搜索遍历的过程如下。
初始条件下所有节点为白色,选择一个作为起始顶点,按照如下步骤遍历:
- a. 选择起始顶点涂成灰色,表示还未访问
- b. 从该顶点的邻接顶点中选择一个,继续这个过程(即再寻找邻接结点的邻接结点),一直深入下去,直到一个顶点没有邻接结点了,涂黑它,表示访问过了
- c. 回溯到这个涂黑顶点的上一层顶点,再找这个上一层顶点的其余邻接结点,继续如上操作,如果所有邻接结点往下都访问过了,就把自己涂黑,再回溯到更上一层。
- d. 上一层继续做如上操作,直到所有顶点都访问过。
以下面一个有向图为例来展示这个过程:
图9:有向图深度优先遍历
具体代码实现:
@Override public List<V> dfs(V root) { List<V> visitedList = Guavas.newArrayList(); Stack<V> visitingStack = new Stack<>(); // 顶点首先压入堆栈 visitingStack.push(root); // 获取一个边的节点 while (!visitingStack.isEmpty()) { V visitingVertex = visitingStack.peek(); GraphNode<V> graphNode = getGraphNode(visitingVertex); boolean hasPush = false; if(null != graphNode) { Set<Edge<V>> edgeSet = graphNode.getEdgeSet(); for(Edge<V> edge : edgeSet) { V to = edge.getTo(); if(!visitedList.contains(to) && !visitingStack.contains(to)) { // 寻找到下一个临接点 visitingStack.push(to); hasPush = true; break; } } } // 循环之后已经结束,没有找到下一个临点,则说明访问结束。 if(!hasPush) { // 获取第一个元素 visitedList.add(visitingStack.pop()); } } return visitedList; }
2、广度优先遍历
广度优先(Breadth First Search, BFS)遍历类似于树的按层次遍历的过程。
广度优先搜索在进一步遍历图中顶点之前,先访问当前顶点的所有邻接结点。
- a.首先选择一个顶点作为起始结点,并将其染成灰色,其余结点为白色。
- b. 将起始结点放入队列中。
- c. 从队列首部选出一个顶点,并找出所有与之邻接的结点,将找到的邻接结点放入队列尾部,将已访问过结点涂成黑色,没访问过的结点是白色。如果顶点的颜色是灰色,表示已经发现并且放入了队列,如果顶点的颜色是白色,表示还没有发现
- d. 按照同样的方法处理队列中的下一个结点。
基本就是出队的顶点变成黑色,在队列里的是灰色,还没入队的是白色。
以下面一个有向图为例来展示这个过程:
图10:有向图广度优先遍历
来看一下具体代码实现:
@Override public List<V> bfs(final V root) { List<V> visitedList = Guavas.newArrayList(); Queue<V> visitingQueue = new LinkedList<>(); // 1. 放入根节点 visitingQueue.offer(root); // 2. 开始处理 V vertex = visitingQueue.poll(); while (vertex != null) { // 2.1 获取对应的图节点 GraphNode<V> graphNode = getGraphNode(vertex); // 2.2 图节点存在 if(graphNode != null) { Set<Edge<V>> edgeSet = graphNode.getEdgeSet(); //2.3 将不在访问列表中 && 不再处理队列中的元素加入到队列。 for(Edge<V> edge : edgeSet) { V target = edge.getTo(); if(!visitedList.contains(target) && !visitingQueue.contains(target)) { visitingQueue.offer(target); } } } //3. 更新节点信息 // 3.1 放入已经访问的列表 visitedList.add(vertex); // 3.2 当节点设置为最新的元素 vertex = visitingQueue.poll(); } return visitedList; }