4.1无向图-算法四

    技术2026-01-06  9

    基本概念:

    图的术语

    一种图的表示方法,能够处理大型而稀疏的图和图处理相关的类的设计模式,其实现算法通过在相关的类的构造函数中对图进行预处理,构造所需的数据结构来高效支持用例对图的查询深度优先搜索和广度优先搜索支持符号作为图的顶点名的类

    算法实现:

    定义: 图是一组顶点和一组能够将两个顶点相连的边组成的 特殊的图:

    自环:即一条连接一个顶点和其自身的边平行边:连接同一对顶点的两条边 数学家把含有平行边的图称为多重图,而将没有平行边或者自环的图称为简单图.

    术语表

    相邻: 当两个顶点通过一条边相邻时,我们称两个顶点是相邻的,并称这条边依附于这两个顶点

    度数:某个顶点的度数即为依附于他的边的总数.

    子图:子图是由一幅图的所有边的一个子集(以及他们所依附的所有顶点)组成的图.

    路径:在图中,路径是由边顺序连接的一系列顶点.

    简单路径是一条没有重复顶点的路径.

    环:环是一条至少含有一条边且起点和终点相同的路径

    简单环:简单环是一条(除去终点和起点必须相同之外)不含重复顶点和边的环.路径或者环的长度是其包含的边数

    连通图:如果从任意一个顶点都存在一条路径到达另一个任意顶点,我们称这幅图是连通图.

    极大联通子图:一幅非联通的图由若干联通的部分组成,他们都是其最大连通子图 一般来说处理一张图需要一个个处理他的连通分量(子图)

    树: 树是一幅无环连通图.互不相连的树组成的集合成为森林.

    生成树:连通图的生成树是他的一幅子图,它含有图中的所有顶点且是一棵树.图的生成树森林是他的所有连通子图的生成树的集合 当且仅当一幅含有V个结点的图G满足下列5个条件之一时,他就是一棵树

    G有V-1条边且不含有环

    G有V-1条边且是连通的

    G是连通的,但是删除任意一条边都会使得他不在连通

    G中的任意一对顶点之间仅存在一条简单路径 我们会学会寻找生成树和森林的算法

    图的密度:图的密度是指已经连接的顶点对占所有可能连接的顶点对的比例.

    稀疏图:在稀疏图中,链接的顶点很少

    稠密图:在稠密图中,只有少部分顶点对之间没有边连接

    二分图:是一种能够将所有结点分为两个部分的图,其中图的每条边所连接的两个顶点都分别属于不同的部分.二分图会出现在很多场景之中

    我们会首先研究一种表示图的API及其实现,然后学习一些查找图和鉴别连通分量的经典算法.最后我们会考虑真实世界中的一些图应用,他们的顶点的名字坑你不是整数并且含有数目庞大的顶点和边

    表示无向图的数据类型

    图的基本操作API

    这个API含有两个构造函数,有两个方法用来分别返回图中的顶点数和边数.有一个方法用来添加一条边,toString()方法和adj()方法用来允许用例遍历给定顶点的所有相邻顶点.值得注意的是,本届学习的所有算法都是基于adj()方法抽象的基本操作

    最常用的图处理代码

    图的表示方法-邻接表数组

    我们可以使用一个以顶点为索引的列表数组,其中的每个元素都是和该顶点相邻的顶点列表.这种数据结构能够同时满足典型应用所需的两个条件

    非稠密图的标准表示称之为邻接表的数据结构(Graph) 要添加一条链接w与v的边,我们将w添加到v的邻接表中并把v添加到w的邻接表中.因此,在这个数据结构中,每条边会出现两次,这个Graph实现的性能有如下特点

    使用的控件和V+E成正比添加一条边所需的时间是常数遍历顶点V的所有相邻顶点所需的时间和V的度数成正比(处理每个相邻顶点所需的时间为常数)

    Graph数据类型

    package 图.无向图; import java.util.Iterator; import java.util.List; import java.util.Scanner; /** * Graph数据结构 */ public class Graph { private final int V; //顶点数目 private int E; //边的数目 private List<Integer>[] adj;//邻接表数组,相邻顶点的数目 /** * 根据顶点的数目初始化邻接表数组 * @param V */ public Graph(int V){ this.V = V; this.E = 0; adj = new List[this.V]; } /** * 初始化邻接表数组并且根据E初始化边的情况 * @param in */ public Graph(Scanner in){ this(in.nextInt());//读取V并初始化邻接表数组 int E = in.nextInt();//读取E for (int i = 0; i < E; i ++){// int v = in.nextInt();//读取顶点 int w = in.nextInt();//读取另一条顶点 addEdge(v,w);//添加一条链接他们的边 } } /** * 添加一条边,无向图邻接表双向添加 * @param v * @param w */ public void addEdge(int v, int w){ adj[v].add(w); adj[w].add(v); } public int V(){ return V; } public int E(){ return E; } //返回某个顶点的所有邻接顶点 public Iterator<Integer> adj(int v){ return adj[v].iterator(); } }

    Graph实现的性能复杂度

    图的处理算法的设计模式

    因为我们会讨论大量关于图处理的算法,所以设计的***首要目标***是将图的表示和实现分离开. 为此我们会为每一个任务创建一个对应的类,用例可以创建相应的对象来完成任务. 类的构造函数一般会在预处理中构造各种数据结构,以有效的响应用例的请求. 典型的用例程序会构造一幅图,将图传递给实现了某个算法的类(作为构造函数的参数),然后调用用例的方法来获取图的各种性质.

    图处理算法的API

    图处理用例热身

    深度优先搜索

    探索迷宫是一种很古老的算法叫做Tremaux搜索.要探索迷宫中的所有通道需要:

    选择一条没有标记过的通道,在你走的路上铺一条绳子标记所有你第一次路过的路口和通道当来到标记过的路口时,用绳子退回上一个路口当回退的路口已经没有可走的通道时,继续回退

    图的搜索方法-深度优先搜索-DFS

    package 图.无向图; import java.util.Iterator; /** * 深度优先搜索 */ public class DepthFirstSearch { //记录和起点连通的所有顶点 private boolean[] marked; private int count;//连通顶点数目 /** * 根据顶点数目初始化需要标记数组 * @param graph * @param s */ public DepthFirstSearch(Graph graph, int s){ marked = new boolean[graph.V()]; } /** * 深度优先搜索.找到与S连通的所有顶点 * @param graph * @param s */ public void dfs(Graph graph, int s){//图,起点 marked[s] = true;//将他标记为已经访问 count++;//连通顶点++ Iterator<Integer> iterator = graph.adj(s); while (iterator.hasNext()){//递归访问该顶点所有没有被标记过的邻接的所有顶点 int w = iterator.next(); if (!marked[w]){ dfs(graph,w); } } } //w和s是连通的吗 public boolean marked(int w){ return marked[w]; } //与s连通的顶点总数 public int count(){ return count; } }

    搜索连通图的经典递归算法描述(遍历所有的边和顶点):

    要搜索一幅图,主需要用一个递归的方法来遍历所有的顶点.在访问其中一个顶点的时候:

    将他标记为已经访问递归的访问他的所有没有标记过的邻居顶点

    这种方法叫做深度优先搜索.它使用一个boolean数组来记录和起点连通的所有顶点.递归方法会标记给定的顶点并调用自己来访问该顶点的相邻顶点列表中所有没有被标记过的顶点.如果图是连通的,每个邻接表中的元素都会被检查到

    命题A: 深度优先搜索标记与起点连通的所有顶点所需的时间和顶点的度数成正比

    单向通道

    在无向图的深度优先搜索中,在碰到边v-w时,要么进行递归调用(w没有被标记过),要么跳过这条边(w已经被标记过).第二次从另一个方向w-v遇到这条边时,总是会忽略它,因为他的另一端v肯定已经被访问过了(在第一次遇到这条边的时候)

    我们现在已经能够将诶绝很多问题

    连通性-路径检测问题

    给定一幅图,回答两个顶点是否连通?或者图中有多少个连通子图?等类似问题 问题两个顶点是否连通等价于两个给定的顶点之间是否存在一条路径?也许应该叫做路径检测问题

    单点路径

    给定一幅图和一个起点s,回答"从s到给定的顶点v是否存在一条路径?如果有找出这条路径"等类似问题

    寻找路径

    路径的API

    实现算法扩展了DeepFirstSearch,添加了一个实例变量edgeTo[]整型数组来起到Tremaux搜索中绳子的作用.这个数组可以找到从每个与s连通的顶点回到s的路径.他会记住每个顶点到起点的路径. 为了做到这一点,在由边v-w第一次访问任意w时,将edgeTo[w]设为v来记住这条路径.换句话说,v-w是从s到w的路径上的最后一条已知的边.这样搜索结果是一个以起点为根节点的树,edgeTo[]是一颗由父链接表示的树. 要找到s到任意顶点v的路径,需要用变量x遍历整棵树,将x设为edgeTo[x],然后在到达s之前,将遇到的所有顶点都压入栈中.

    使用深度搜索查找图中的路径

    package 图.无向图; import netscape.security.UserTarget; import java.util.Iterator; import java.util.Stack; /** * 使用深度优先搜索查找图中的路径 */ public class DepthFirstPaths { private boolean[] marked; private int []edgeTo; private final int s; public DepthFirstPaths(Graph graph, int s){ marked = new boolean[graph.V()];//这个顶点调用过dfs吗 edgeTo = new int[graph.V()];//从起点到一个顶点的已知路径上的最后一个顶点,起点到每个连通顶点的路径 this.s=s;//起点 dfs(graph,this.s);//深度优先搜索,预处理,得到与起点连通的所有顶点以及起点到每一个连通顶点的完整路径 } /** * 深度优先搜索 * @param graph * @param v */ public void dfs(Graph graph, int v){ marked[v] = true;//标记访问后的顶点 Iterator<Integer> iterator = graph.adj(v); while (iterator.hasNext()){ int w = iterator.next(); if (!marked[w]){//遍历该顶点还没有被访问的邻接点 edgeTo[w]=v;//从s-w的路径已知的最后一个顶点 dfs(graph,w); } } } /** * 从s-v是否存在一条连通路径 * @param v * @return */ public boolean hasPathTo(int v){ return marked[v]; } /** * 从s到v的完整路径 * @param v * @return */ public Stack<Integer> pathTo(int v){ if (!marked[v]){ return null; } //使用栈,符合先进后出原则 Stack<Integer> path = new Stack<>(); for (int i = v; i != s; i = edgeTo[i]){ path.push(i); } path.push(s); return path; } }

    命题:深度优先搜索得到的从给定起点到任意标记顶点的路径所需时间与路径的长度成正比

    广度优先搜索

    单点最短路径:给定一幅图和一个起点s,回答"从s到给定目的顶点v是否存在一条路径?"如果有找出其中最短的那条(所含边数最少)等类似问题 解决这个问题的经典方法叫做广度优先搜索(BFS)

    要找到从s到v的最短路径,从s开始,在所有由一条边就可以到达的顶点寻找v,如果找不到我们就继续在与s距离两条边的所有顶点中查找v,如此一直进行.深度优先搜索就好像是一个人走进迷宫,广度优先搜索则好像是一群人在朝向各个方向走迷宫,每个人都有自己的绳子.当出现新的支路的时候,可以假设一个探索者可以分裂为更多的人搜索他们,当两个搜索者相遇时又可以合二为一(并继续使用先到达者的绳子)

    在广度优先搜索中,我们希望按照与起点的距离来遍历所有顶点,看起来这种顺序很容易实现:(使用FIFO)队列来替代栈(LIFO)即可.我们将从有待搜索的通道中选择最早遇到的那条

    实现步骤: 使用了一个队列来保存所有已经被标记过,但是其邻接表还未被检查过的顶点.先将顶点加入队列,然后重复一下步骤直到队列为空:

    取队列中的下一个顶点v并标记他将与v相邻的所有未被标记过的顶点加入队列 package 图.无向图; import sun.misc.Queue; import java.util.Iterator; import java.util.Stack; /** * 广度优先搜索 */ public class BreadthFirstPaths { //到达该顶点的最短路径已知吗 private boolean[] marked; //到达该顶点的已知路径上的最后一个顶点 private int[] edgeTo; //起点 private final int s; public BreadthFirstPaths(Graph graph, int s) throws InterruptedException { //根据顶点数目初始化需要被标记的顶点数目 marked = new boolean[graph.V()]; edgeTo = new int[graph.V()]; this.s=s; bfs(graph,s); } /** * 广度优先搜索 * @param graph * @param s * @throws InterruptedException */ public void bfs(Graph graph, int s) throws InterruptedException { marked[s] = true;//标记起点 Queue<Integer> queue = new Queue<>(); queue.enqueue(s);//将他加入队列 while (!queue.isEmpty()){ int v = queue.dequeue();//从队列中删去下一顶点 Iterator<Integer> iterator = graph.adj(v); while (iterator.hasNext()){ int w = iterator.next(); if (!marked[w]){//对于每个未被标记的相邻顶点 edgeTo[w]=v;//到达w顶点最短路径上的已知最后一个顶点是v marked[w]=true;//标记顶点,因为到达他的最短路径已知 queue.enqueue(w);//将他加入队列,已被标记但是邻接表未被标记 } } } } public boolean hasPathTo(int v){ return marked[v]; } public Stack<Integer> pathTo(Graph graph,int v){ if (!marked[v]){ return null; } Stack<Integer> path = new Stack<>(); // Iterator<Integer> iterator = graph.adj(v); // while (iterator.hasNext()){ // int w = iterator.next(); // // } for (int w = v; w != s; w=edgeTo[w]){ path.push(w); } path.push(s); return path; } } 命题B:对于从s可达的任意顶点v,广度优先搜索都能找到一条从s到v的最短路径(没有其他从s到v的路径所含的边比这条路径更少)命题B续:广度优先搜索所需要的时间在最坏的情况下和V+E成正比

    连通分量

    深度优先搜索的下一个直接应用就是找出一幅图的所有连通分量.它能够将顶点且分为等价类(l联通分量).对于这个任务,我们定义一下API CC的实现使用了marked[]数组来寻找一个顶点作为每连通分量中深度优先搜索的起点.递归的深度优先搜索第一次调用的参数是顶点0-他会标记所有与0连通的顶点.然后构造函数中的for循环会查找每个没有被标记过的顶点并递归调用dfs()来标记和他相邻的所有顶点.另外他还使用了一个以顶点为索引的数组id[],将同一个连通分量中的顶点和连通分量中的标识符关联起来(int).这个数组使得connected()方法的实现变得十分简单(只需要检查标识符是否相同).这里的标识符0会被赋予第一个连通分量中的所有顶点.1会被赋予第二个连通分量中的所有结点,以此类推.这样所有的标识符都会如API指定的那样在0-count()-1之间 这个约定使得子图作为索引的数组成为可能.

    package 图.无向图; import java.util.Iterator; /** * 使用深度优先搜索 */ public class CC { private boolean[]marked;//寻找一个顶点作为每个连通分量中深度优先搜索的起点 //将连通分量中的顶点和标识符关联起来 private int []id; //连通分量标识符 private int count; /** * 构造函数预处理 * @param graph */ public CC(Graph graph){ //该顶点是否是联通的 marked = new boolean[graph.V()]; //连通分量标识 id = new int[graph.V()]; //当前标识符 count=0; for (int i = 0; i < graph.V(); i ++){ if (!marked[i]){//寻找一个顶点作为连通分量中深度优先搜索的起点 dfs(graph,i); //确保一个连通分量使用一个标识符 count ++; } } } /** * 深度优先搜索 * @param graph * @param v */ public void dfs(Graph graph, int v){ //进行标记,存在连通 marked[v]=true; //设置该顶点所属的连通分量 id[v]=count; Iterator<Integer> iterator = graph.adj(v); while (iterator.hasNext()){ int w = iterator.next(); if (!marked[w]){ dfs(graph,w); } } } }

    我们为下面两个问题做出解答

    检测环:给定的图是无环图吗(假设不存在自环和平行边)双色问题:能够用两种颜色将图的所有顶点着色,使得任意一条边的两个端点的颜色都不相同,这个问题也等价于.这是一幅二分图吗 package 图.无向图; import java.util.Iterator; /** * 使用深度优先搜索判断G是无环图吗(假设不存在自环或者平行边) * 只需要判断当一个顶点的邻接顶点已经被标记并且和自己作为终点的边的起始点不相同即可 * 设顶点w,是顶点s的邻接顶点,如果w的邻接顶点x已经被标记而且存在x!=s则可以判断存在环 */ public class Cycle { private boolean []masked; private boolean hasCycle; public Cycle(Graph graph){ masked = new boolean[graph.V()]; for (int v = 0; v < graph.V(); v++){ if (!masked[v]){ dfs(graph,v,v); } } } /** * 深度优先搜索 * @param graph * @param v 当前顶点 * @param u 当前顶点作为终止点时候的起始点 * w != u 存在环 w==u标识只有一条邻接边 */ public void dfs(Graph graph, int v, int u){ //标记 masked[v]=true; Iterator<Integer>iterator = graph.adj(v); while (iterator.hasNext()){ int w = iterator.next(); if (!masked[w]){//如果没有被标记继续深度优先搜索 dfs(graph,w, v); } else if ( w != u){//如果标记顶点不是上一级顶点 hasCycle=true;//存在环 } } } public boolean hasCycle(){ return hasCycle; } } package 图.无向图; import java.util.Iterator; /** * G是二分图吗(双色问题) * 核心还是在邻接顶点已经被标记的情况下判断一条边的两个顶点是否相同 */ public class TwoColor { //是否是连通的也就是已经被标记过 private boolean[]masked; //每个顶点的颜色 private boolean[]color; //是双色图嘛 private boolean isTwoColorable = true; /** * 构造函数预处理 * @param graph */ public TwoColor(Graph graph){ masked = new boolean[graph.V()]; color = new boolean[graph.V()]; for (int i = 0; i < graph.V(); i ++){ dfs(graph,i); } } /** * 深度优先搜索 * @param graph * @param v 起点 */ private void dfs(Graph graph, int v){ masked[v]=true; Iterator<Integer> iterator = graph.adj(v); while (iterator.hasNext()){ int w = iterator.next(); if (!masked[w]){ color[v]=!color[w]; dfs(graph,w); } //如果一条边的两个顶点颜色一致 else if(color[v] == color[w]){ isTwoColorable = false; } } } /** * 是否是双色图 * @return */ public boolean isBipartite(){ return isTwoColorable; } }

    符号图

    在典型应用中,图都是tongguo0文件或者网页定义的,使用的是字符串而非整数来表示和指代顶点.为了适应这样的应用,我们定义了如下性质的输入格式

    顶点名为字符串用指定分隔符隔开顶点名(允许顶点名中含有空格)每一行都表示一组边的集合,每一条边都连着这一行的第一个名称表示的顶点和其他名称所表示的顶点顶点总数V和E是隐形定义的

    电影和演员关系的实例:

    电影和演员都是顶点,而邻接表中的每一条边都将电影和他的表演者联系起来.注意这是一幅二分图-电影顶点之间或者演员结点之间都没有边相连

    用符号做顶点名的图的API

    反向索引:

    输入演员的名字查找电影的列表相当于查找反向索引.尽管数据库的构造是为了将电影名连接到演员,二分图的模型同时也意味着演员连接到电影名.二分图的性质自动完成了反向索引.

    实现:

    他使用了如下三种数据结构

    一个符号表st,键的类型是String(顶点名),值的类型为int(索引)一个数组keys[],用作反向缩影,保存每个顶点索引对应的顶点名一个Graph对象,他使用索引来引用图中的顶点 package 图.无向图; import java.util.HashMap; import java.util.Map; import java.util.Scanner; /** * 符号图的数据类型 */ public class SymbolGraph { private Map<String,Integer> st; //符号名->索引 private String []keys; //索引->符号名 private Graph G; public SymbolGraph(String stream, String sp){ st = new HashMap<>(); Scanner in = new Scanner(stream);//第一遍 while (in.hasNextLine()){//构造索引 String []a=in.nextLine().split(sp);//读取字符串 for (int i = 0; i < a.length; i ++){//为每一个不同的字符串关联一个索引 if (!st.containsKey(a[i])){ st.put(a[i],st.size()); } } keys = new String[st.size()];//用来获得顶点名的反向索引是一个数组 for (String name : st.keySet()){ keys[st.get(name)]=name; } G = new Graph(st.size());//构造图 in = new Scanner(stream);//第二遍 while (in.hasNextLine()){ a = in.nextLine().split(sp);//将每一行的第一个顶点与该行的其他顶点相连 int v = st.get(a[0]); for (int i = 1; i < a.length; i ++){ G.addEdge(v,st.get(a[i])); } } } } public boolean contains(String s){ return st.containsKey(s); } public int index(String s){ return st.get(s); } public String name(int v){ return keys[v]; } public Graph G(){ return G; } }

    这个Graph实现允许用例使用字符串代替数字索引来标识图中的顶点.他维护了一个实例变量st(符号表用来映射顶点名和索引),keys(数组用来映射索引和顶点名)和G(使用索引标识顶点的图).为了构造这些数据结构,代码会将图处理两边(定义的每一行都包含一个顶点以及他的相邻顶点列表,用分隔符sp隔开)

    间隔的度数-Kevin Bacon游戏

    Processed: 0.047, SQL: 9