图
一系列点以及把它们连起来的边就构成了一幅图,图是现实生活中许多东西的抽象比如地图或者电路图,在数学中也有图论这一分支专门研究图的性质,这一篇以及接下来的几篇都是和图相关的。
上图是一幅图的示例。
这里我们先研究无向图,就是图中的边是没有方向的。还要介绍几个定义:
度数:某一个顶点的度数即为依附于它的边的总数。
路径:由边顺序连接起来的一系列顶点。
环:该路径上的任意一个顶点都可以沿着这条路径回到原来的顶点。
连通图:如果从任意一个顶点都存在一条路径到达另一个任意定点,那么这幅图是连通图。
我们这里不考虑自环和平行边。
图的表示方式
图的表示有很多种,比如领接矩阵,但是这种表示方式浪费了很大的空间,所以我们采用邻接表的方式,就是维护一个数组adj[],adj[]里的每一个元素对应于一个顶点,里面保存的是一个指向一个Bag<>泛型数组的引用,这个Bag数组里面包含的是和某个顶点v相连的所有顶点。
一个无向图的邻接表的实现方式如上图所示,使用邻接表可以只保留有效信息,从而大大减少空间的需求。
Graph实现的代码为:
public class Graph {
private final int V; // number of vertex
private int E; // number of edge
private Bag<Integer>[] adj; //adjacent list
public Graph(int V){
this.V = V;
this.E = 0;
adj = (Bag<Integer>[]) new Bag[V];
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);
adj[w].add(v);
E++;
}
public Iterable<Integer> adj(`
nt v){
return adj[v];
}
}
上面的代码中Bag的实现来自于Sedgewick教授根据课程所发布的algs4程序库。这种对图的实现方式有如下特性:
1. 使用的空间和V+E成正比
2. 添加一条边所需的时间为常数
3. 遍历顶点v所有的相邻顶点所需的时间和v的度数成正比
对所有数据结构,我们基本都想要一个方法能够遍历里面所有的数据,对于较简单的栈或者队列,我们可以直接按照元素加入的相关顺序返回一个Iterable<>对象,对于二叉搜索树或者红黑树,我们可以按照大小返回一个迭代对象,对于散列表,我们很难得到什么顺序信息,所以我们不会在用它的时候返回迭代数据,但同时我们牺牲这个性质以及部分空间换来的是几乎常数级的查找速度。
现在对于图,我们也需要一个操作,遍历所有数据,并且它最好能够反映节点之间的相关信息,所以就有了两个很重要的搜索方法,深度优先和广度优先搜索,下面先介绍深度优先搜索。
深度优先搜索(DepthFirstSearch)
深度优先搜索的主要特征就是,假设一个顶点有不少相邻顶点,当我们搜索到该顶点,我们对于它的相邻顶点并不是现在就对所有都进行搜索,而是对一个顶点继续往后搜索,直到某个顶点,他周围的相邻顶点都已经被访问过了,这时他就可以返回,对它来的那个顶点的其余顶点进行搜索。
深度优先搜索的实现可以利用递归很简单地实现
public class DepthFirstSearch {
private boolean[] marked;
private int count;
public DepthFirstSearch(Graph G, int s){
marked = new boolean[G.V()];
dfs(G,s);
}
private void dfs(Graph G, int v){
marked[v] =true;
count++;
for (int w:G.adj(v))
if (!marked[w]) dfs(G, w);
}
public boolean marked(int w){
return marked[w];
}
public int count(){return count;}
}
代码中使用一个boolean[]数组标识某个顶点是否被访问过,这在以后与图相关的算法中很常
见,因为我们必须直到哪些顶点被访问过了
如果不好理解的话可以仔细看看下面的图:
深度优先方式寻找路径
上面的代码只是一个遍历方法,加一些东西就能够进行路径的查找。
主要的思想就是,利用深度优先进行全面的搜索,同时维护一个edgeTo[]数组,保存到达每个顶点的上一个顶点。深度优先搜索一定会遍历一个连通图中所有的顶点,如果两个顶点在一个连通图中,那么他们两个一定会被访问到,然后edgeTo[]保存了所有的路径,那么对于想要查找的顶点,我们就能够得到起点到它的路径。
代码如下:
public class DepthFirstPaths {
private boolean[] marked;
//判断顶点是否被访问过
private int[] edgeTo;
//保存到某顶点的上一个顶点
private final int s;
//起点
public DepthFirstPaths(Graph G, int s){
marked =new boolean[G.V()];
edgeTo = new int[G.V()];
this.s = s;
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);
}
}
public boolean hasPathTo(int v){
return marked[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){
java.io.File file = new java.io.File("./Graph/mediumG.txt");
//从《算法4》网站上下载的测试用例
Graph G = new Graph(new In(file));
int so=0;
int v=150;
DepthFirstPaths dppath = new DepthFirstPaths(G, so);
String s =dppath.hasPathTo(v)? "is":"isn't";
System.out.println("There "+ s +" a path to vertex "+v);
if (dppath.hasPathTo(v))
System.out.println(dppath.pathTo(v));
}
}
广度优先搜索(BreadthFirstSearch)
广度优先搜索相对于深度优先搜索侧重点不一样,深度优先好比是一个人走迷宫,一次只能选定一条路走下去,而广度优先搜索好比是一次能够有任意多个人,一次就走到和一个顶点相连的所有未访问过的顶点,然后再从这些顶点出发,继续这个过程。
具体实现的时候我们使用先进先出队列来实现这个过程
1. 首先将起点加入队列,然后将其出队,把和起点相连的顶点加入队列,
2. 将队首元素v出队并标记他
3. 将和v相连的未标记的元素加入队列,然后继续执行步骤2直到队列为空
广度优先搜索的一个重要作用就是它能够找出最短路径,这个很好理解,因为广度优先搜索相当于每次从一个起点开始向所有可能的方向走一步,那么第一次到达目的地的这个路径一定是最短的,而到达了之后由于这个点已经被访问过而不会再被访问,这个路径就不会被更改了。
下面是利用广度优先进行路径搜索的代码:
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);
}
}
}
public boolean hasPathTo(int v){
return marked[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;
}
}
这段代码中函数pathTo()返回的就是两点之间的最短路径,当然这只是对于简单的无向图来说的,对于有向图以及加权的图情况又有所不同。
广度优先搜索在最坏的情况下所需的时间和V+X成正比其中V+X就是所有定点的度数,若图是连通的,则为2E。
连通分量
一个图可能是不连通的,那么找出图中有几个连通分量,找出两个顶点是否位于同一个连通分量都可以用深度优先搜索或者广度优先搜索完成,但是深度优先搜索实现起来更简单。
我们先看代码实现:
public class CC {
private boolean[] marked;
private int[] id;
private int count;
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++;
}
}
}
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];
}
public int id(int v){
return id[v];
}
public int count(){
return count;
}
}
代码中维护了两个数组,一个是常见的marked[]数组,用来表示是否被访问过,另一个是id[]数组,用来判定每个顶点属于那么连通分量。
在构造函数中我们使用了深度优先搜索函数dfs,对于一个顶点来说,dfs()会遍历这个顶点所在的连通分量所有的顶点才会返回,接着执行下一语句也就是count++刚好可以给下一个连通分量使用,而这些语句外围的for循环则可以保证会遍历所有顶点,这样就能找出所有连通分量并将它们区分开来。
深度优先搜索的预处理时间使用的时间和空间和V+E成正比且可以在常数时间内处理关于图的连通性的查询
检测是否为无环图
检测无环图的代码有点难以理解,首先代码如下:
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 v, int u){
marked[v] = true;
for(int w: G.adj(v)){
if (!marked[w])
dfs(G, w, v);//可以看出第二个函数表示该顶点的上一个顶点
else if (w!=u) hasCycle = true;
}
}
public boolean hasCycle(){
return hasCycle;
}
}
这里有两个关键,一个是为什么要将深度优先搜索函数dfs()设计成有两个参数的,观察for语句里面的递归用法,就知道了,第二个参数代表当前顶点的上一个顶点是哪一个。然而这有什么用?这里需要知道一点连通无环图就是一颗树,然后我们以叶节点为例,考虑深度优先搜索进行到这里的情况,假设叶节点为w ,它的上一个节点为v,那么我们首先调用了dfs(G,w,v),然后是marked[w] = true;节点w被访问,然后进入for循环,这时候w的相邻节点只有v,if循环被跳过,在else语句里面v==v那么hasCycle就不会被置为false,所以综上,这样设计成两个参数就是因为在无向图里面两个顶点互相相邻,那么就一定会有子顶点会访问到父顶点的情况,这时候访问到的已经被访问的顶点就是上一个顶点,而如果图里面有环那么显而易见,这个被访问过的顶点并不是上一个顶点,这样就能够检测出环了。
判断是否为二分图
public class TwoColor {
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 = false;
//相邻顶点的颜色相同,该图一定不是二分图
}
}
public boolean isBipartite(){
return isTwoColorable;
}
}
这段代码很好理解,其实就相当于我们进行人为地上色,在深度优先搜索的过程中我们将该节点与他的上一个节点的“颜色”求反,所以一旦碰到两个相邻顶点的颜色相同,那么这个图一定不是二分图。