无向图
定义:
图是由一组顶点和一组能够将两个顶点(vertex)相连的边(edge)组成的
在无向图中,边仅仅起连接两个顶点的作用
特殊的图:(定义允许的两种简单而特殊的形式)
- 自环:即一条连接其顶点和自身的边
- 连接同一顶点的两条边称为平行边
通常将含有平行边的图称为多重图,不含平行边的图称为简单图,接下来的讨论中,考虑的是不含平行边的情况,所以可以利用两个节点指代一条边
术语:
相邻:
两个顶点通过一条边相连
依附:
称连接相邻两点的边依附于这两点
度数:
某个点依附边的总数
子图:
由一幅图所有边的子集(以及边所依附的点)组成的图
路径:
由边顺序连接的一系列顶点,简单路径表示没有重复顶点的路径,环表示起点终点相同的路径,简单环表示除了起点终点相同外,不含有重复顶点和边的环。
路径或者环的长度等于边的个数
连通图:
从任意顶点都存在一条路径能够到达另一任意顶点,一幅非连通的图由若干连通部分组成,每一部分都是其极大连通子图
树:
树是一副无环连通图,互不相连的树的集合称为森林
生成树:
连通图的生成树,是它的一幅子图,含有图中所有顶点且是一棵树。
生成树森林:
所有连通子图的生成树的集合
图的密度:
已经连接的顶点占所有可能被连接的顶点的
二分图:
一种能够将所有节点分为两部分的图,其中图的每条边所连接的两个结点都分别属于不同的部分
树的数学性质:
当且仅当一幅含有V个结点的图G满足下列5个条件之一时,它就是一棵树:
- G有V-1条边且不含有环
- G有V-1条边且是连通的
- G是连通的,但删除任意一条边都会使它不再连通
- G是无环图,但添加任意一条边都会产生一条环
- G中的任意一对顶点之间仅存在一条简单路径
无向图API
返回值 | 函数名 | 作用 |
---|---|---|
构造函数 | Graph(int V) | 创建一个含有V个顶点但不含边的图 |
构造函数 | Graph(In in) | 从标准输入流读入一幅图 |
int | V() | 顶点数 |
int() | E() | 边数 |
void | addEdge(int v,int w) | 向图中添加一条边v-w |
Iterable | adj(int v) | 和v相邻的所有顶点 |
String | toString() | 对象的字符串表示 |
常用的图处理代码
package cn.ywrby.tools;
import edu.princeton.cs.algs4.Graph;
public class GraphTool {
//计算节点v的度
public static int degree(Graph G,int v){
int degree=0;
for(int w:G.adj(v)) degree++;
return degree;
}
//计算所有顶点的最大度数
public static int maxDegree(Graph G){
int max=0;
for(int v=0;v<G.V();v++){
if(degree(G,v)>max) max=degree(G,v);
}
return max;
}
//计算所有顶点的平均度数
public static double avgDegree(Graph G){
return 2.0*G.E()/G.V();
}
//计算自环个数
public static int numberOfSelfLoops(Graph G){
int count=0;
for(int v=0;v<G.V();v++){
for(int w:G.adj(v)){
if(w==v) count++;
}
}
return count;
}
}
图的几种表示方法:
前提:
- 必须能够适应可能存在的极大的图,也就是为各种图预留足够的空间
- Graph实例方法一定要快,这是开发多种用例,以及实际操作的前提
三种表示方法:
邻接矩阵
利用V*V大小的布尔矩阵表示节点之间的连通关系,比如第v行w列的值为TRUE表示v,w两个点连通
缺陷是无法处理大型问题,占用空间过大。同时无法表示平行边的存在
边的数组
构造专门的边的数据结构Edge类,存储两个整型值,表示依附的两个点
缺陷是实现adj方法时需要遍历所有边,时间消耗过大
邻接表数组
创建大小等于节点数量的数组,其中每个位置又存储链表,链表内存储与当前节点相连通的所有节点。
成功处理了上文的两种情况
邻接表数据结构
邻接表中用来储存某一结点相连节点的链表利用背包Bag来实现,这使得我们可以在常数时间内添加新的边或遍历任意顶点的所有相邻顶点
这样实现的Graph有如下特性:
- 使用的空间和V+E成正比
- 添加一条边所需时间为常数
- 遍历一个顶点的所有相邻边与该节点的度成正比
Graph数据类型代码实现
package cn.ywrby.Graph;
import edu.princeton.cs.algs4.Bag;
import edu.princeton.cs.algs4.In;
public class Graph {
private final int V; //图中顶点数目
private int E; //边的个数
private Bag<Integer>[] adj; //邻接表
//构造函数
public Graph(int V){
this.V=V;
E=0;
for(int v=0;v<V;v++){
adj[v]=new Bag<Integer>();
}
}
public Graph(In in){
this(in.readInt());
int E=in.readInt();
for(int i=0;i<E;i++){
int v=in.readInt();
int w=in.readInt();
addEdge(v,w);
}
}
public int V(){return V;}
public int E(){return E;}
public void addEdge(int v,int w){
adj[v].add(w); //向v的链表中写入顶点w
adj[w].add(v); //向w的链表中写入顶点v
E++; //增加边的数目
}
public Iterable<Integer>adj(int v){return adj[v];}
}
深度优先搜索(DFS)
通过类似走迷宫的方式,利用Tremaux搜索解决
主要流程就是从起点开始,标记自己经过的每一个顶点
在下一次碰见标记节点的情况下就按原路返回上一个节点
如果返回到一个节点,该顶点的每一条道路都已经有标记就沿原路继续返回
在深度优先搜索中想要搜索一幅图,只需要:
- 递归的遍历整个图中所有顶点
- 在经过一个顶点时对它进行标记
- 递归的访问它的所有没被标记过的邻居节点
深度优先搜索标记与起点连通的所有顶点所需的时间和顶点的度数之和成正比
首先,这个算法能够标记与起点s相连的所有顶点(且不会标记其他点):因为算法仅通过边来寻找顶点,所以每个被标记的点一定跟s连通,另一方面,假设s与没有被标记过的w相连,这说明两个顶点之间一定存在一段分界连线,这条连线一边是标记的,另一边是未标记的,根据定义,只要有一边是标记的,另一边经过遍历一定会被标记的。所以自相矛盾,说明已经标记了所以s的顶点
单点路径问题:
给定一幅图和一个起点s,能否从s到达顶点v,如果能,返回路径。
深度优先搜索代码实现
package cn.ywrby.Graph;
import cn.ywrby.dataStructure.Stack;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
/*
* 深度优先搜索
* 通过类似走迷宫的方式,利用Tremaux搜索解决
* 主要流程就是从起点开始,标记自己经过的每一个顶点
* 在下一次碰见标记节点的情况下就按原路返回上一个节点
* 如果返回到一个节点,该顶点的每一条道路都已经有标记
* 就沿原路继续返回
* */
public class DepthFirstPaths {
private boolean[] marked; //布尔数组用来存储每个顶点的标记情况
private int[] edgeTo;
//存储从起点到一个顶点已知路径上的最后一个顶点,
//其实就是搜索的上一条路,通过这个数组可以得到起点到终点的完整路径
private final int s; //起点
public DepthFirstPaths(Graph G,int s){
this.s=s;
marked=new boolean[G.V()]; //初始化布尔数组
edgeTo=new int[G.V()];
dfs(G,s); //进行深度优先搜索
}
//深度优先搜索
private void dfs(Graph G,int v){
marked[v]=true; //标记当前节点
for(int w:G.adj(v)){ //遍历每一条路径
if(!marked(w)){ //如果没有标记
edgeTo[w]=v; //确定上一个顶点
dfs(G,w); //对它继续进行深度优先搜索
}
}
}
//是否有到达v的路径
public boolean hasPathTo(int v){
return marked[v];
}
//w顶点是否被标记
public boolean marked(int w){return marked[w];}
//返回前往v的路径
public Iterable<Integer> pathTo(int v){
if(!hasPathTo(v)) return null;
Stack<Integer> path=new Stack<Integer>();
for(int x=v;x!=s;x=edgeTo[x]){
path.push(x);
}
path.push(s);
return path;
}
public static void main(String[]args){
Graph G=new Graph(new In(args[0]));
int s=Integer.parseInt(args[1]);
DepthFirstPaths search=new DepthFirstPaths(G,s);
for(int v=0;v<G.V();v++){
StdOut.print(s+" to "+v+": ");
if(search.hasPathTo(v)){
for(int x:search.pathTo(v)){
if(x==s) StdOut.print(x);
else StdOut.print("-"+x);
}
}
StdOut.println();
}
}
}
深度优先搜索流程
使用深度优先搜索得到从给定起点到任意标记顶点的路径所需的时间与路径长度成正比
根据归纳不难得出,edgeTo数组表示一棵以起点为根节点的数,pathTo()方法构造路径所需时间和路径长度(树的深度)成正比
广度优先搜索(BTS)
相较于深度优先搜索,广度优先搜索每次得到的路径都是短路径
这在实际处理问题中更具有优势,避免了因查找顺序导致随机
实现的流程就是,从起点开始,一旦遇到多叉路径,就把所有未标记路径都插入到队列中,同时删除来时路径对应顶点,不断重复直到队列为空
实现流程
- 先将起点加入队列,然后重复2,3步骤直到队列为空
- 取出队列中的下一个顶点v并标记它
- 将所有与v相邻的未标记顶点加入到队列中
广度优先搜索解决了单点最短路径问题,即给定一幅图和一个起点s,是否存在到达顶点v的路径,如果存在,最短路径是什么
代码实现广度优先搜索
package cn.ywrby.Graph;
import cn.ywrby.dataStructure.Queue;
import cn.ywrby.dataStructure.Stack;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
/*
* 广度优先搜索
* 相较于深度优先搜索,广度优先搜索每次得到的路径都是最短路径
* 这在实际处理问题中更具有优势,避免了因查找顺序导致的随机
* 实现的流程就是,从起点开始,一旦遇到多叉路径
* 就把所有未标记路径都插入到队列中
* 同时删除来时路径对应顶点
* 不断重复直到队列为空
* */
public class BreadthFirstPaths {
private boolean[] marked;
private int[] edgeTo;
private final int s;
//初始化广度优先搜索
public BreadthFirstPaths(Graph G,int s){
marked=new boolean[G.V()];
edgeTo=new int[G.V()];
this.s=s;
bfs(G,s);
}
private void bfs(Graph G,int s){
//创建队列用于临时存放顶点
Queue<Integer>queue=new Queue<Integer>();
marked[s]=true;
queue.enqueue(s); //将顶点放入队列
while(!queue.isEmpty()){
int v=queue.dequeue(); //删除来时路径对应的节点
for(int w:G.adj(v)){ //遍历被删除顶点的所有未标记路径
if(!marked[w]){
edgeTo[w]=v;
marked[w]=true;
queue.enqueue(w);
}
}
}
}
//是否有到达v的路径
public boolean hasPathTo(int v){
return marked[v];
}
//返回前往v的路径
public Iterable<Integer> pathTo(int v){
if(!hasPathTo(v)) return null;
Stack<Integer> path=new Stack<Integer>();
for(int x=v;x!=s;x=edgeTo[x]){
path.push(x);
}
path.push(s);
return path;
}
public static void main(String[]args){
Graph G=new Graph(new In(args[0]));
int s=Integer.parseInt(args[1]);
BreadthFirstPaths search=new BreadthFirstPaths(G,s);
for(int v=0;v<G.V();v++){
StdOut.print(s+" to "+v+": ");
if(search.hasPathTo(v)){
for(int x:search.pathTo(v)){
if(x==s) StdOut.print(x);
else StdOut.print("-"+x);
}
}
StdOut.println();
}
}
}
广度优先搜索所需的时间在最坏的情况下和V+E成正比
连通分量
深度优先搜索的下一个直接应用就是寻找一幅图中所有连通分量
代码实现
package cn.ywrby.Graph;
//使用深度优先搜索计算一幅图中连通分量的个数
import edu.princeton.cs.algs4.Bag;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdOut;
public class CC {
private boolean[] marked;
private int[] id; //id数组用来存储每个顶点所在的连通分量
private int count; //计算图中连通分量个数
/*
*从起点0开始,利用深度优先搜索标记所有与0连通的分量
*然后继续利用for循环找到下一个没有与0连通的顶点
*继续对它进行深度优先搜索
*知道所有顶点都被标记过
*/
public CC(Graph G){
marked=new boolean[G.V()];
id=new int[G.V()];
for(int s=0;s<G.V();s++){
if(!marked[s]){
dfs(G,s);
count++;
}
}
}
//深度优先搜索,这里的深度优先搜索省去了对edgeTo
private void dfs(Graph G,int v){
marked[v]=true; //标记当前节点
id[v]=count;
for(int w:G.adj(v)){ //遍历每一条路径
if(!marked[w]){ //如果没有标记
dfs(G,w); //对它继续进行深度优先搜索
}
}
}
//判断两个顶点是否连通
public boolean connected(int v,int w){return id[v]==id[w];}
//返回当前节点id
public int id(int v){return id[v];}
public int count(){return count;}
public static void main(String[] args) {
Graph G=new Graph(new In(args[0]));
//进行深度优先搜索处理连通分量
CC cc=new CC(G);
int M=cc.count();
StdOut.println(M+" components");
//创建一个背包,存储所有连通分量
Bag<Integer>[] components;
components=(Bag<Integer>[])new Bag[M];
for(int i=0;i<M;i++){
components[i]=new Bag<Integer>();
}
//将每个顶点,按照所在连通分量加到背包中
for(int v=0;v<G.V();v++){
components[cc.id(v)].add(v);
}
for(int i=0;i<M;i++){
for(int v:components[i]){
StdOut.print(v+" ");
}
StdOut.println();
}
}
}
使用深度优先搜索的预处理使用的时间和空间与V+E成正比且可以在常数时间内处理关于图的连通性查询
深度优先搜索来处理连通分量计算问题与用union-find算法相比。在运行时间分析上,深度优先搜索更占优势,但实际处理中,UF算法是一种动态处理算法,在任何时候都能在常数时间内计算两点连通情况,而深度优先搜索必须处理完全图,才能做出操作
使用深度优先搜索计算其他常见问题:
无环图问题:G是无环图吗?(假设在不存在自环或平行边的情况下)
代码实现:
package cn.ywrby.Graph.DepthFirstPaths;
//利用深度优先搜索判断该图是否为无环图
import cn.ywrby.Graph.Graph;
public class Cycle {
private boolean[] marked;
private boolean hasCycle; //记录是否含有环
public Cycle(Graph G){
marked =new boolean[G.V()];
for(int s=0;s<G.V();s++){
if(!marked[s]) dfs(G,s,s);
}
}
private void dfs(Graph G,int s,int u ){
marked[s]=true;
for(int w:G.adj(s)){
if(!marked[w]) dfs(G,w,s);
else if(w!=u) hasCycle=true;
}
}
public boolean hasCycle(){return hasCycle;}
}
使用深度优先搜索解决双色问题,判断一幅图是否是一幅双色图
代码实现:
package cn.ywrby.Graph.DepthFirstPaths;
//利用深度优先搜索判断G是二色图吗
import cn.ywrby.Graph.Graph;
public class TwoColor {
private final boolean RED=true;
private final boolean BLACK=false;
private boolean[] marked;
private boolean[] color;
private boolean isTwoColorable=true;
public TwoColor(Graph G){
marked =new boolean[G.V()];
color =new boolean[G.V()];
for(int s=0;s<G.V();s++){
if(!marked[s]) dfs(G,s);
}
}
private void dfs(Graph G,int v){
marked[v]=true;
for(int w:G.adj(v)){
if(!marked[w]){
color[w]=!color[v];
dfs(G,w);
}
else if(color[w]=color[v]) isTwoColorable=true;
}
}
public boolean isBipartite(){return isTwoColorable;}
}
符号图
我们规定符号图应该允许以下的输入格式:
- 顶点名为字符串
- 用指定分隔符来隔开两个顶点名(允许顶点名中出现空格等字符)
- 每一行都表示一条边,也就是一行上有两个字符串型的顶点名
- 定点总数V和边数E是隐式的(不需要再显示定义这两个变量,而是在实现时利用两次遍历,获取到这些数据,增加符号图的可操作性)
符号图使用了三种数据结构
- 符号表 st中,键的类型为String-顶点名,int-索引
- 数组keys[],用作反向索引,保存每个顶点索引对应的顶点名
- 一个Graph对象G使用索引来引用图中顶点
符号图会遍历两遍数据来构造以上三种数据结构,第一次遍历创建符号表和keys数组,并且获取定点总数V和边的总数E,第二次遍历创建图Graph
代码实现
package cn.ywrby.Graph;
//符号表
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.ST;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;
public class SymbolGraph {
private ST<String,Integer> st; //符号名->索引
private String[] keys; //索引->符号名
private Graph G; //图
//创建符号表,stream表示读入的文件,用来创造图,sp表示分隔符
public SymbolGraph(String stream,String sp){
st=new ST<String, Integer>();
//第一遍遍历数据,用来获取Graph对象顶点数V并且将对应字符值和索引存入结构中
In in=new In(stream);
while(in.hasNextLine()){
String[] a=in.readLine().split(sp); //拆分放进数组
for(int i=0;i<a.length;i++){
if(!st.contains(a[i])){ //只要不含在数组中的都加入
st.put(a[i],st.size()); //按顺序存入
}
}
}
keys=new String[st.size()]; //用来储存对应顶点名的反向索引
for(String name:st.keys()){
keys[st.get(name)]=name;
}
G=new Graph(st.size());
//第二次遍历,开始构造图
in=new In(stream);
while(in.hasNextLine()){
String[] a=in.readLine().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.contains(s);}
//返回对应字符串的索引
public int index(String s){return st.get(s);}
//返回索引对应的顶点名
public String name(int v){return keys[v];}
//返回图
public Graph G(){return G;}
public static void main(String[] args) {
String filename=args[0];
String delim=args[1];
SymbolGraph sg=new SymbolGraph(filename,delim);
Graph G=sg.G();
while(StdIn.hasNextLine()){
String source=StdIn.readLine();
for(int w:G.adj(sg.index(source))){
StdOut.println(" "+sg.name(w));
}
}
}
}