第四章 图
4.2 有向图
在有向图中,边是单边的:每条边所连接的两个顶点都是一个有序对,他们的邻接性是单向的。
4.2.1 术语
一幅有方向性的图(或有向图)是由一组顶点和一组有方向的边组成的,每条有方向的边都连接着有序的一对顶点。
在一幅有向图中,有向路径由一系列顶点组成,对于其中的每个顶点都存在一条有向边从它指向序列的下一个顶点。
有向环为一条至少含有一条边且起点和终点相同的有向路径。
简单有向环是一条(除了起点和终点必须相同之外)不含有重复的顶点和边的环。
我们假设有向路径都是简单的,除非我们明确指出了某个重复了的顶点。
4.2.2有向图的数据类型
4.2.2.1 有向图的表示
我们使用邻接表来表示有向图,其中边v->w表示为顶点v所对应的邻接链表包含一个w顶点。
4.2.2.2 有向图取反
API中添加了一个方法reverse()。它返回有向图的一个副本,但将其中所有边的方向反转。
Digraph数据类型
代码:
public class Digraph {
private final int V;
private int E;
private Bag[] adj;
public Digraph(int V) {
this.V = V;
this.E = 0;
adj = (Bag[]) new Bag[V];
for (int i = 0; i < V; i++) {
adj[i] = new Bag<>();
}
}
public int getE() {
return E;
}
public int getV() {
return V;
}
public void addEdge(int v, int w) {
adj[v].add(w);
E++;
}
public Iterable adj(int v) {
return adj[v];
}
public Digraph reverse() {
Digraph R = new Digraph(V);
for (int v = 0; v < V; v++) {
for (int w : adj[v])
R.addEdge(w, v);
}
return R;
}
public String toString() {
String s = V + " vertices," + E + " edges\n";
for (int v = 0; v < V; v++) {
s += v + ": ";
for (int w : this.adj(v)) {
s += w + " ";
}
s += "\n";
}
return s;
}
}
4.2.3有向图的可达性
单点可达性。给定一幅有向图和一个起点s,回答"是否存在一条从s到给定顶点v的有向路径?"等类似问题。
在添加了一个接受多个顶点的构造函数之后,这份API使得用力能够解决一个更加一般的问题。
多点可达性。给定一幅有向图和顶点的集合,回答“是否存在一条从集合中的任意顶点到达给定顶点v 的有向路径?”等类似问题。
算法:有向图的可达性
public class DirectedDFS {
private boolean[] marked;
public DirectedDFS(Digraph g, Integer s) {
marked = new boolean[g.getV()];
dfs(g, s);
}
public DirectedDFS(Digraph g, Iterable sources) {
marked = new boolean[g.getV()];
for (int s : sources) {
dfs(g, s);
}
}
private void dfs(Digraph g, int v) {
marked[v] = true;
for (int w : g.adj(v)) {
if (!marked[w])
dfs(g, w);
}
}
public boolean marked(int v) {
return marked[v];
}
}
4.2.3.2 有向图的寻路
DepthFirstPaths和BreadthFirstPaths是有向图处理的重要代码。它们可以高效地解决以下问题。
单点有向路径。给定一幅有向图和一个起点s,回答“从s到给定目的顶点v是否存在一条有向路径?如果有,找出这条路径”等类似问题
单点最短有向路径。给定一幅有向图和一个起点s,回答“从s到给定目的顶点v是否存在一条有向路径?如果有找到其中最短的那条(所含边数最少)”等类似问题。
/**
* 有向图的深度优先搜索路径
*/
class DepthFirstDirectedPaths {
private boolean[] marked; //标记是否被访问过
private int[] edgeTo; //一直顶点的路径上的最后一个顶点
private final int s; //起点
public DepthFirstDirectedPaths(Digraph g, int s) {
this.s = s;
marked = new boolean[g.getV()];
edgeTo = new int[g.getV()];
dfs(g, s);
}
private void dfs(Digraph g, int v) {
marked[v] = true;
for (int w : g.adj(v)) {
if (!marked[v]) {
edgeTo[w] = v;
dfs(g, w);
}
}
}
private boolean hasPathTo(int v) {
return marked[v];
}
public Iterable pathTo(int v) {
if (!hasPathTo(v)) return null;
Stack stack = new Stack<>();
for (int x = v; v != s; x = edgeTo[x]) {
stack.push(x);
}
stack.push(s);
return stack;
}
}
/**
* 有向图的广度优先搜索路径
*/
class BreadthFirstDirectedPaths {
private boolean[] marked; //标记是否被访问过
private int[] edgeTo; //一直顶点的路径上的最后一个顶点
private final int s; //起点
public BreadthFirstDirectedPaths(Digraph g, int s) {
this.s = s;
marked = new boolean[g.getV()];
edgeTo = new int[g.getV()];
bfs(g, s);
}
private void bfs(Digraph g, int v) {
marked[v] = true;
Deque stack = new ArrayDeque<>();
stack.offer(v);
while (!stack.isEmpty()) {
int t = stack.poll();
for (int w : g.adj(t)) {
if (!marked[w]) {
marked[w] = true;
edgeTo[w] = t;
stack.offer(w);
}
}
}
}
private boolean hasPathTo(int v) {
return marked[v];
}
public Iterable pathTo(int v) {
if (!hasPathTo(v)) return null;
Stack stack = new Stack<>();
for (int x = v; v != s; x = edgeTo[x]) {
stack.push(x);
}
stack.push(s);
return stack;
}
}
4.2.4环和有向无环图
从原则上来说,一幅有向图可能含有大量的环;在实际应用中,我们一般只会重点关注其中一小部分,或者只是想知道它们是否存在
寻找有向环
基于深度优先搜索的来解决这个问题并不困难,系统维护的递归调用的栈正是“当前”正在遍历的有向路径。一旦我们找到了一条有向边v->w且w已经存在于栈中,就找到了一个环,因为栈表示的是一条由w到v的有向路径,而v->w正好补全了这个环。同时,如果没有找到这样的边,说明有向图是无环的。
/**
* 寻找有向环
* 如果存在多个有向环,则只找到一个有向环
*/
public class DirectedCycle {
private boolean[] marked;
private int[] edgeTo;
private Stack cycle; //有向环中的所有顶点
private boolean[] onStack; //递归调用的栈上的所有顶点
public DirectedCycle(Digraph g) {
marked = new boolean[g.getV()];
edgeTo = new int[g.getV()];
onStack = new boolean[g.getV()];
for (int v = 0; v < g.getV(); v++) {
if (!marked[v])
dfs(g, v);
}
}
private void dfs(Digraph g, int v) {
marked[v] = true;
onStack[v] = true;
for (int w : g.adj(v)) {
if (hasCycle()) return;
else if (!marked[w]) {
edgeTo[w] = v;
dfs(g, w);
} else if (onStack[w]) {
cycle = new Stack();
for (int x = v; x != w; x = edgeTo[w]) {
cycle.push(x);
}
cycle.push(w);
cycle.push(v);
}
}
onStack[v] = false;
}
public boolean hasCycle() {
return cycle != null;
}
public Iterable cycle() {
return cycle;
}
}
该类为标准的递归bfs()方法添加了一个布尔类型的数组onStack[]来保存递归调用期间栈上的所有顶点。当它找到一条边v->w且w在栈中的时候,就找到了一个有向环。环上的所有顶点可以通过edgeTo[]来得到。
同样,需要指出的是,广度优先搜索也可以找到是否有环,这个问题我们放到后面拓扑排序的时候用例题讨论。
4.2.4.3 顶点的深度优先次序与拓扑排序
实际上我们已经见过一种拓扑排序的算法:只要添加一行代码,深度标准优先搜索程序就能完成这项任务。
如果将dfs()的参数顶点保存在一个数据结构中,遍历这个数据结构实际上就能访问图中的所有顶点,遍历的顺序取决于这个数据结构的性质以及是在递归调用之前还是之后保存。典型的应用中,人们感兴趣的是顶点的以下3中排列顺序。
- 前序:在递归调用之前将顶点加入队列。
- 后序:在递归调用之后将顶点加入队列。
- 逆后序:在递归调用之后将顶点压入栈。
有向图中基于深度优先搜索的顶点排序
public class DepthFirstOrder {
private boolean[] marked;
private Queue pre; //所有顶点的前序排列
private Queue post; //所有顶点的后序排列
private Stack reversePost; //所有顶点的逆后序排列
public DepthFirstOrder(Digraph g) {
marked = new boolean[g.getV()];
pre = new ArrayDeque<>();
post = new ArrayDeque<>();
reversePost = new Stack<>();
for (int v = 0; v < g.getV(); v++) {
if (!marked[v]) dfs(g, v);
}
}
private void dfs(Digraph g, int v) {
marked[v] = true;
pre.add(v);
for (int w : g.adj(v)) {
if (!marked[w])
dfs(g, w);
}
post.add(v);
reversePost.push(v);
}
public Iterable pre() {
return pre;
}
public Iterable post() {
return post;
}
public Iterable reversePost() {
return reversePost;
}
}
拓扑排序
public class Topological {
private Iterable order; //顶点的拓扑排序
public Topological(Digraph g) {
DirectedCycle cycleFinder = new DirectedCycle(g);
if (!cycleFinder.hasCycle()) {
//先判断是否有环,有环的话不能进行拓扑排序
DepthFirstOrder dfs = new DepthFirstOrder(g);
order = dfs.reversePost();
}
}
public Iterable order() {
return order;
}
public boolean isDAG() {
return order != null;
}
}
一副有向图的拓扑顺序即为所有顶点的逆后序排列。
证明。对于任意边v->w,在调用dfs(v)的时候,有以下三种情况必成立
1.dfs(w)已经被调用过且已经返回了(w已经被标记)
2.dfs(w)还没有被调用过(w还未被标记),因此v->w会直接或间接调用并返回dfs(w),且dfs(w)会在dfs(v)返回前返回。
3.dfs(w)已经调用还未被返回。证明的关键在于,在有向无环图中这种情况是不可能的,由于递归调用链意味着存在从w到v的路径,但存在v->w表示存在一个环。
在两种可能的情况中,dfs(w)都会在dfs(v)之前完成,因此在后序排列中w在v之前而在逆后序中v在w之前。因此任意一条边v->w都如我们所愿地从排名较前顶点指向排名较后的顶点。
在实际应用中,拓扑排序和有向环的检测总会在一起出现,因为有向环的检测是排序的前提。
实际应用一下
LeetCode题目:
207. Course Schedule
-
210. Course Schedule II
4.2.5有向图的强连通性
定义。如果两个顶点v和w是互相可达的,则称它们为强连通的。也就是说,既存在一条从v到w的有向路径,也存在一条从w到v的有向路径。如果一幅有向图的任意两个顶点都是强连通的,则称这幅有向图也是强连通的。
4.2.5.1强连通分量
Kosaraju算法
算法完成了以下几点:
- 在给定的一幅有向图G中,使用DepthFirstOrder来计算它的反向图GR的逆后序排列。
- 在G中进行标准的深度优先搜索,但是要按照刚才得到的顺序而非标准的顺序来访问所有未被标记的顶点。
- 在构造函数中,所有在同一个递归dfs()调用访问道德顶点都在同一个强连通分量中
public class KosarajuSCC {
private boolean[] marked; //已经访问过的顶点
private int[] id; //强连通分量的标识符
private int count; //强连通分量个数
public KosarajuSCC(Digraph digraph) {
marked = new boolean[digraph.getV()];
id = new int[digraph.getV()];
DepthFirstOrder order = new DepthFirstOrder(digraph.reverse());
for(int v:order.reversePost())
{
if(!marked[v])
dfs(digraph,v);
}
}
private void dfs(Digraph digraph, int s) {
marked[s] = true;
id[s] = count;
for (int w : digraph.adj(s)) {
if (!marked[w])
dfs(digraph, w);
}
}
public int getCount() {
return count;
}
public boolean stronglyConnected(int v, int w) {
return id[v] == id[w];
}
}
命题。使用深度优先搜索查找给定有向图G的反向图GR,根据由此得到的所有顶点的逆后序再次使用深度优先搜索处理有向图,其构造函数每次递归调用所标记的顶点都在同一个强连通分量中。
证明。首先用反证法证明“每个和s强连通的顶点v都会在构造函数调用的dfs(G,s)中访问到”。假设有一个和s强连通的顶点v不会在构造函数调用的dfs(G,s)中访问到。因为存在从s到v的路径,所以v肯定在之前就已经被标记过了。但是,因为也存在从v到s的路径,在dfs(G,v)调用中s肯定会被标记,因此构造函数不会调用dfs(G,s),矛盾。
其次,证明“构造函数调用的dfs(G,s)所到达的任意顶点v都必然和s强连通“。
设v为dfs(G,s)到达的某个顶点。那么,G必然存在一条s到v的路径,只需要证明G中还存在一条从v到s的路径即可。这也等价于GR中存在一条从s到v的路径,只需要证明在GR中存在一条从s到v的路径即可。
证明的核心在于,按照逆后序进行深度优先搜索与为这,在GR中进行的深度优先搜索中,dfs(G,v)必然在dfs(G,s)之前就已经结束了,这样dfs(G,v)的调用出现两种情况
1.调用在dfs(G,s)调用之前(并且在dfs(G,s))调用之前结束
2.调用在dfs(G,s)调用之后(并且在dfs(G,s))调用之前结束
第一种情况是不可能出现的,因为在GR中存在一条从v到s的路径;而第二种情况说明GR中存在一条从s到v的路径。