图的基本概念:这里就不予介绍了,这里主要是讲图的代码实现
荔枝目录:
- 1.图的存储结构
- 1.1邻接矩阵
- 1.2邻接表
- 2.图的遍历
- 2.1广度优先
- 2.2深度优先
- 3.最小生成树
- 3.1Kruskal算法(全局)
- 3.2Prim算法(局部)
- 4.最短路径
- 4.1单源最短路径算法1--Dijkstra算法
- 4.2单源最短路径算法2--Bellman-Ford算法
1.图的存储结构
1.1邻接矩阵
邻接矩阵是图常见的存储结构
❤️用邻接矩阵存储图的优点是能够快速知道两个顶点是否连通
💕缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间,并且要求两个节点之间的路径不是很好求。
具体做法是通过一个二维数组来记录每一条边或边的权值
无向图:
可以发现是对称的。
有向图:
具体代码实现呢,因为我们是默认没有权值的,如果有需要把这个二维数组的所有元素初始化为最大值(当∞使用)
//顶点集private char[] arrayV;//边集private int[][] arrayE;//是否是有向图private boolean isDirect;//存储顶点下标private static Map<Character,Integer> indexV=new HashMap<>();public MyGraph(int size, boolean isDirect) {arrayV=new char[size];arrayE=new int[size][size];this.isDirect=isDirect;/*for(int i=0;i<arrayE.length;i++){//有权值加上这一块代码Arrays.fill(arrayE[i],Integer.MAX_VALUE);}*/}/*** 初始化顶点集* @param arrV*/public void initArrayV(char[] arrV){for (int i = 0; i < arrV.length; i++) {this.arrayV[i]=arrV[i];indexV.put(arrV[i],i);}}/*** 添加边和对应权重* @param v1* @param v2* @param weight*/public void addEdge(char v1,char v2,int weight){//先找出两个顶点在顶点集的位置--搞个哈希表方便int v1Index=indexV.get(v1);int v2Index=indexV.get(v2);this.arrayE[v1Index][v2Index]=weight;//如果是无向图if(!isDirect){this.arrayE[v2Index][v1Index]=weight;}}/*** 把边集的二维数组打印出来*/private void printGraph() {for(int i=0;i< arrayE.length;i++){for (int j = 0; j < arrayE[i].length; j++) {System.out.print(arrayE[i][j]+" ");}System.out.println();}}
测试用例:
public static void main(String[] args) {MyGraph graph = new MyGraph(4,false);char[] array = {'A','B','C','D'};graph.initArrayV(array);graph.addEdge('A','B',1);graph.addEdge('A','D',1);graph.addEdge('B','A',1);graph.addEdge('B','C',1);graph.addEdge('C','B',1);graph.addEdge('C','D',1);graph.addEdge('D','A',1);graph.addEdge('D','C',1);graph.printGraph();}
结果:
1.2邻接表
这里通过结点的形式将该边的起点和终点记录,接到对应的链表上
结点结构:
static class Node{public int src;//起始点下标public int dest;//终止点下标public int weight;//权值public Node next;public Node(int src, int dest, int weight) {this.src = src;this.dest = dest;this.weight = weight;}}
图示:
当然也有使用入边和出边分开保存的,这里借用图片:
具体代码:
//邻接结点static class Node{public int src;//起始点下标public int dest;//终止点下标public int weight;//权值public Node next;public Node(int src, int dest, int weight) {this.src = src;this.dest = dest;this.weight = weight;}}//顶点集private char[] arrayV;//是否是有向图private boolean isDirect;//存储顶点下标private static Map<Character,Integer> indexV=new HashMap<>();//存储每个顶点的边结点链表private List<Node> list;public MyGraph2(int size,boolean isDirect){this.arrayV=new char[size];this.isDirect=isDirect;list=new ArrayList<>(size);//给个初始大小并且置为null,方便后面判断for (int i = 0; i < size; i++) {list.add(null);}}/*** 初始化顶点集* @param arrV*/public void initArrayV(char[] arrV){for (int i = 0; i < arrV.length; i++) {this.arrayV[i]=arrV[i];indexV.put(arrV[i],i);}}/*** 插入边结点到链表中* @param v1* @param v2* @param weight*/public void addEdge(char v1,char v2,int weight){int src=indexV.get(v1);int dest=indexV.get(v2);addEdgeChild(src,dest,weight);//如果是无向图,需要反着再添加一次if(!isDirect){addEdgeChild(dest,src,weight);}}private void addEdgeChild(int src,int dest,int weight) {Node node=new Node(src,dest,weight);//头插入法Node cur= list.get(src);//获取src的链表头位置if(cur==null){list.set(src,node);}else{Node tmp=list.get(src);node.next=tmp;list.set(src,node);}}public void printGraph() {for (int i = 0; i < arrayV.length; i++) {System.out.print(arrayV[i]+"-> ");Node cur = list.get(i);while (cur != null) {System.out.print(cur.dest+"-> ");cur = cur.next;}System.out.println("null");}}
测试用例:
public static void main(String[] args) {MyGraph2 graph = new MyGraph2(3,true);char[] array = {'A','B','C'};graph.initArrayV(array);graph.addEdge('A','B',1);graph.addEdge('B','A',1);graph.addEdge('B','C',1);graph.printGraph();}
结果:
2.图的遍历
2.1广度优先
核心是确定首位置点,遍历时先遍历与该点最近的点,再遍历第二近的点,直到遍历完成。
这个看起来和二叉树的层序遍历很像,没错,我们在层序遍历二叉树的时候利用了一个队列,那这里也需要一个队列。不过只一个队列还不行,还需要一个数组来记录某个点是否已经遍历过。因为点与点直接是互连的,会出现一个点重复进入队列的情况。
具体代码实现:
public void bfs(char start){boolean isAppear[]=new boolean[arrayV.length];//默认为falseDeque<Integer> deque=new ArrayDeque();//Integer为记录点的下标deque.offer(indexV.get(start));//添加第一个点并记录该点已经出现isAppear[indexV.get(start)]=true;while(!deque.isEmpty()){int top=deque.poll();//弹出第一个点System.out.print(arrayV[top]+":"+top+"->");//加入下一个点for (int i = 0; i < arrayE.length; i++) {if(arrayE[top][i]!=0&&isAppear[i]==false){//添加点并先记录该点已经出现isAppear[i]=true;deque.offer(i);}}}}
这里需要再加入的时候就设置已经出现,如果在弹出的时候才设置就会导致多打印最后一个点。
2.2深度优先
和二叉树的前序遍历差不多,都是一条路先走到头再回溯换路
但是这里也需要判断当前点是否已经遍历过了,防止重复遍历。
具体代码如下:
public void dfs(char start){boolean isAppear[]=new boolean[arrayV.length];//默认为falseint index=indexV.get(start);dfsChild(index,isAppear);}private void dfsChild(int cur, boolean[] isAppear) {isAppear[cur]=true;System.out.print(arrayV[cur]+"->");for (int i = 0; i < arrayE.length; i++) {if(arrayE[cur][i]!=0&&isAppear[i]==false){dfsChild(i,isAppear);}}}
3.最小生成树
基本概念
一个连通图的最小连通子图称作该图的生成树。 连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树 就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三 条:
- 只能使用图中的边来构造最小生成树
- 只能使用恰好n-1条边来连接图中的n个顶点
- 选用的n-1条边不能构成回路 构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体
最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解
3.1Kruskal算法(全局)
克鲁斯卡尔算法的核心是,每次在边集取一条权重最小的边加入生成树。这个算法可能会产生回路问题,所以每次取边都需要判断取该条边会不会使生成树有回路。
图示:
从这个图我们可以提取到我们代码的核心:
1.怎么每次取一条权值最小的边?
2.如何判断回路?
第一个问题可以采用优先级队列(小根堆)的方式来处理
第二个问题可以采用并查集的方式来处理,把已经连上的点看成一个集合,比如在图f中如果想选择左下的7,我们就到并查集里查找i和h点在不在同一个集合中在就不能选。
这里图的存储结构采用邻接矩阵
代码分析:
/**** @return 返回最小生成树的权值*/public int kruskal(MyGraph minTree){int totalWeight=0;//优先级队列存储边--小根堆PriorityQueue<Edge> minHead=new PriorityQueue<>(new Comparator<Edge>() {@Overridepublic int compare(Edge o1, Edge o2) {return o1.weight-o2.weight;//自定义类型要传比较器重写compare方法确定比较条件}});//将所有边加入优先集队列for (int i = 0; i < arrayE.length; i++) {for (int j = 0; j < arrayE[i].length; j++) {if(arrayE[i][j]!=Integer.MAX_VALUE){Edge edge=new Edge(i,j,arrayE[i][j]);minHead.add(edge);}}}//创建一个并查集来方便检查有没有回路UnionFindSet loops=new UnionFindSet(arrayV.length);//n个结点需要n-1条边int size=0;int n=arrayV.length;while (size<n-1&&!minHead.isEmpty()){//取出边Edge top=minHead.poll();//判断这条边的加入会不会产生回路if(loops.isSameSet(top.srcIndex,top.destIndex)==false){//加入到生成树中minTree.addEdgeUserIndex(top.srcIndex,top.destIndex,top.weight);//将两点加入同一个集合loops.union(top.srcIndex,top.destIndex);//累加权值totalWeight+=top.weight;size++;}}//判断边不够的情况---做不了生成树--也就是不是连通图if(minHead.isEmpty()){return -1;}return totalWeight;}
测试代码:
public static void testGraphMinTree() {String str = "abcdefghi";char[] array = str.toCharArray();MyGraph g = new MyGraph(str.length(), false);g.initArrayV(array);g.addEdge('a', 'b', 4);g.addEdge('a', 'h', 8);g.addEdge('b', 'c', 8);g.addEdge('b', 'h', 11);g.addEdge('c', 'i', 2);g.addEdge('c', 'f', 4);g.addEdge('c', 'd', 7);g.addEdge('d', 'f', 14);g.addEdge('d', 'e', 9);g.addEdge('e', 'f', 10);g.addEdge('f', 'g', 2);g.addEdge('g', 'h', 1);g.addEdge('g', 'i', 6);g.addEdge('h', 'i', 7);MyGraph kminTree = new MyGraph(str.length(), false);System.out.println(g.kruskal(kminTree));kminTree.printGraph();}
结果:
3.2Prim算法(局部)
和克鲁斯卡尔算法不同的是,普利姆算法采用局部贪心的思想,从任意点出发,将已经加入生成树的点和未加入的点分成两个集合X,Y,在连接这两个集合中任意两点的边里选择一条权值最小的。选完之后,将Y集合的那个点加入X集合中。这个方法天生就是没有回路的,因为取边的时候Y的点肯定不和X的点在同一个集合。
图示:
这里在代码中主要问题有:
1.X和Y的集合怎么表示?
2.如何在两个集合中任意两点的边里选择一条权值最小的边?
第一个问题可以通过哈希表来表示集合
第二个问题依然采用优先级队列,每往X集合加入一个点,我们就把这个点占有的所有边加入优先级队列,每次取边时以Y集合的点为起点,判断取的这条边的另一个点即终点在不在X集合里。因为我们加入边的时候不是所有边都能取到
就像图8一样,那个7虽然小,但取不得。
代码分析:
/**** @param minTree* @return 最小生成树的权值*/public int prim(MyGraph minTree){int totalWeight=0;//优先级队列存储边--小根堆PriorityQueue<Edge> minHead=new PriorityQueue<>(new Comparator<Edge>() {@Overridepublic int compare(Edge o1, Edge o2) {return o1.weight-o2.weight;//自定义类型要传比较器重写compare方法确定比较条件}});//创建X,Y集合 存下标Set<Integer> X=new HashSet<>();Set<Integer> Y=new HashSet<>();int start=0;//任意点均可X.add(start);for (int i = 0; i < arrayV.length; i++) {if(i!=start)Y.add(i);}//初始化队列--根据X的内容来初始化for (int j = 0; j < arrayE[start].length; j++) {if(arrayE[start][j]!=Integer.MAX_VALUE){Edge edge=new Edge(start,j,arrayE[start][j]);minHead.add(edge);}}int size=0;int n=arrayV.length;while(size<n-1&&!minHead.isEmpty()){Edge top=minHead.poll();//取的这条边的另一个点即终点在不在X集合里if(!X.contains(top.destIndex)){//加入生成树minTree.addEdgeUserIndex(top.srcIndex,top.destIndex,top.weight);//将来自Y的这个点destIndex加入X中,Y要删除它,并更新边的队列X.add(top.destIndex);Y.remove(top.destIndex);//每选一条边 就打印一条语句 测试一下System.out.println(arrayV[top.srcIndex] + "-> " + arrayV[top.destIndex] + " : " + top.weight);for (int j = 0; j < arrayE[top.destIndex].length; j++) {if(arrayE[top.destIndex][j]!=Integer.MAX_VALUE&&!X.contains(j)){//加入边的时候不能加入了a->b还加入b->aEdge edge=new Edge(top.destIndex,j,arrayE[top.destIndex][j]);minHead.add(edge);}}totalWeight+=top.weight;size++;}}//判断边不够的情况---做不了生成树--也就是不是连通图if(minHead.isEmpty()){return -1;}return totalWeight;}
测试用例:
public static void test2GraphMinTree() {String str = "abcdefghi";char[] array = str.toCharArray();MyGraph g = new MyGraph(str.length(), false);g.initArrayV(array);g.addEdge('a', 'b', 4);g.addEdge('a', 'h', 8);g.addEdge('b', 'c', 8);g.addEdge('b', 'h', 11);g.addEdge('c', 'i', 2);g.addEdge('c', 'f', 4);g.addEdge('c', 'd', 7);g.addEdge('d', 'f', 14);g.addEdge('d', 'e', 9);g.addEdge('e', 'f', 10);g.addEdge('f', 'g', 2);g.addEdge('g', 'h', 1);g.addEdge('g', 'i', 6);g.addEdge('h', 'i', 7);MyGraph primTree = new MyGraph(str.length(), false);int total=g.prim(primTree);System.out.println(total);primTree.printGraph();}
结果:
4.最短路径
最短路径问题:从在带权图的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小
4.1单源最短路径算法1–Dijkstra算法
注:单源就是给你一个起点让你求和其他点的最短路径,多源就是任意两点的最短路径。
迪杰斯特拉算法思想:
针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q 为其余未确定最短路径的结点集合,每次从Q中找出一个起点到该结点代价最小的结点u ,将u 从Q 中移出,并放入S 中,对u 的每一个相邻结点v 进行松弛操作。松弛即对每一个相邻结点v ,判断源节点s到结点u 的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样。如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。
缺点:不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路
径。例如:就找不到a->c->b这条最短路径
代码分析
主要问题:1.如何表示该点已经固定
2.如何获取我们的最短路径(怎么走的)
解答:
1.可通过boolean数组表示
2.通过pPath数组下标对应值来表示,例如:
代码:
public void dijkstra(char vSrc,int[] dist,int[] pPath){//获取起点下标int srcIndex=indexV.get(vSrc);//初始化dist数组--初始全无穷,起点下标设成0Arrays.fill(dist,Integer.MAX_VALUE);dist[srcIndex]=0;//初始化pPath数组--初始全-1,起点的下标设为自己的下标Arrays.fill(pPath,-1);pPath[srcIndex]=srcIndex;//表示某点已经固定--初始全falseboolean[] isSure=new boolean[dist.length];int n=dist.length;for (int k = 0; k < n; k++) {//要松弛的结点int u=srcIndex;//每次循环确定一个最小值固定,并确定下一次要松弛的结点int min=Integer.MAX_VALUE;for (int i = 0; i < n; i++) {if(dist[i]!=Integer.MAX_VALUE&&isSure[i]==false&&dist[i]<min){min=dist[i];u=i;}}//固定找到的最小值isSure[u]=true;//对u点进行松弛操作for (int v = 0; v < n; v++) {/*判断源节点s到结点u 的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样*///v顶点未固定 并且 u->v 是有权值的 并且 加起来的权值 < dist[v]if(isSure[v]==false&&arrayE[u][v]!=Integer.MAX_VALUE&&arrayE[u][v]+dist[u]<dist[v]){dist[v]=arrayE[u][v]+dist[u];//修改最小值pPath[v]=u;//修改路径}}}}
4.2单源最短路径算法2–Bellman-Ford算法
迪杰斯特拉算法虽好,但是解决不了带负权的图,那么要处理有负权图的最短路径,就要用到贝尔曼-福特算法。
它的优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。它也有明显的缺点,它的时间复杂度 O(N*E)(N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),这里也可以看出来贝尔曼-福特算法就是一种暴力求解更新。
注意负权不能有负权回路!不然永远都求不到最短。例如:
接下来我们来研究一下这个算法是如果处理的吧!
这个算法就是对N个点每个点都进行N次松弛操作,因为后一次的负权路径可能会影响到前面的最短路径,例如:我们按a,b,c,d依次进行一次松弛操作可以发现
所以,单单一次松弛是不能够搞定的,每个结点所以要进行n次松弛操作才能保证我们的结果是正确的。
代码:
代码和迪杰斯特拉算法的代码很相似,但不再需要判断该点是否固定。
/**** @param vSrc* @param dist* @param pPath* @return 是否存在负权回路*/public boolean bellmanFord(char vSrc,int[] dist,int[] pPath) {//获取起点下标int srcIndex=indexV.get(vSrc);//初始化dist数组--初始全无穷,起点下标设成0Arrays.fill(dist,Integer.MAX_VALUE);dist[srcIndex]=0;//初始化pPath数组--初始全-1,起点的下标设为自己的下标Arrays.fill(pPath,-1);int n=arrayV.length;//循环n次松弛结点for (int k = 0; k < n; k++) {for (int i = 0; i < n; i++) {for (int j = 0; j < n; j++) {if(arrayE[i][j]!=Integer.MAX_VALUE&&dist[i]+arrayE[i][j]<dist[j]){dist[j]=dist[i]+arrayE[i][j];pPath[j]=i;}}}}//判断是否存在负权回路,再松弛一遍还能修改就代表存在for (int i = 0; i < n; i++) {for (int j = 0; j < n; j++) {if(arrayE[i][j]!=Integer.MAX_VALUE&&dist[i]+arrayE[i][j]<dist[j]){return false;}}}return true;}