-
- 数据结构之图
- 定义(百度百科)
- 图的术语表
- 无向图
- 深度优先搜索
- 广度优先遍历
- 有向图
- 路径问题
- 调度问题
- 强连通性
- 最小生成树(无向图)
- 最小生成树的贪心算法
- 加权无向图的数据结构
- Kruskal算法
- Prim算法
- 性能特点:(V个顶点E条边)
- 最短路径
- 定义(百度百科)
- 数据结构之图
在上上篇博客中,我们介绍了算法中中的查找算法,其中大部分是在介绍查找算法中需要用得到的数据结构。在这一篇博客中,我们将来开启图的新篇章。
图的源码
数据结构之图
在前面我们所介绍的树的数据结构中,我们可以明显的感觉到,树的表示是分层的,例如父子关系,而其他关系只能间接的表示,例如同级关系。而图却不受这种限制。图是由顶点(或结点)及顶点之间的关系组成的集合。通常,图中的顶点数量或者一个顶点与其他顶点之间的连线的个数不受限制。(C++数据结构与算法)
定义(百度百科)
主要有以下两种定义。
二元组的定义:
图G是一个有序二元组(V,E),其中V称为顶集(Vertices Set),E称为边集(Edges set),E与V不相交。它们亦可写成V(G)和E(G)。
E的元素都是二元组,用(x,y)表示,其中x,y∈V。
三元组的定义:
图G是指一个三元组(V,E,I),其中V称为顶集,E称为边集,E与V不相交;I称为关联函数,I将E中的每一个元素映射到 。如果e被映射到(u,v),那么称边e连接顶点u,v,而u,v则称作e的端点,u,v此时关于e相邻。同时,若两条边i,j有一个公共顶点u,则称i,j关于u相邻。
在介绍图之前,首先让我们来了解一下图中的一个重要的分类。图的术语特别的多,不过我们可以慢慢的了解,因为定义都比较简单(我将在下面慢慢的介绍一些术语)。
-
无向图:图是有一组顶点和一组能够将两个顶点相连的边组成的。可能概念有点模糊,但是可以根下面的有向图相比较就特别简单了。
-
有向图:由一组顶点和一组有方向的边组成,每条有方向的边都连接着有序的一对顶点
(这张来自百度百科的图片都快糊了)
图的分类其实很多,但是我们主要介绍的就是这两种分类,还有一些分类可能会在接下来的博客中提到(我也不确定会不会提到,还没写)
图的术语表
-
相邻:如果两个顶点通过一条边相连, 则称这两个顶点是相邻的,并称这条边依附于这两个顶点
-
度数:某个顶点的度数即为依附于它的边的总数。
-
子图:一幅图的所有边的一个子集以及他们所依附的所有顶点组成的图。
-
路径:由边顺序链接的一系列顶点。
-
简单路径:一条没有重复顶点的路径。
-
环:一条至少包含一条边且起点和终点相同的路径。
-
简单环:除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的环。
-
连通图:任意两个顶点之间互通。一副非连通的图由诺干个连通的部分组成。
-
图的密度:已连接的顶点对占所有可能被连接的顶点对的比例。
-
平行边:连接同一对顶点的两条边称为平行边。
-
二分图:图中的每一条边所连接的两个顶点都分别属于不同的部分,如下图所示:
在这一章博客中,我所讲的内容会偏向于算法,并不会在数据结构上面说很多内容。
无向图
OK,在前面说完这么多,首先让我们来说下最简单的图:无向图
不过在说在无向图的操作之前,我们至少得解决一个问题:我们使用如何的结构去储存图。在前面我们知道,图不是像树一样(绝大部分的树),只需要关心父子关系,而不需要关心兄弟关系。简单点来说,就是树的关系是纵向的(从上到下),而图却并不是这样,图之间的关系是并列的。相信看过图这种数据结构的人,应该对于图的储存结构的方式可以说的信口拈来吧。下面是一些图的储存的方法:
图源:geeksforgeeks
-
邻接矩阵表示法
下图一眼就可以看懂,如果结点a与结点b之间相连接,则A(a,b) = A(b,a) = 1,否则为0。
-
邻接表表示法
在邻接表表示法中,第一列代表的为结点,如0,1,2……,而后面的则代表为结点与其他结点相连接的结点。(例如0结点后面为1,4结点,则代表0结点与1结点和4结点相连接【在这里我们可以发现,第5行的4结点的后面同样有1结点】)
-
关联矩阵表示法
那么我们该选择哪一种的表示方式呢?两种各有优缺点:
-
如果我们需要处理顶点V的邻接顶点,我们使用邻接表只需要deg(v)步操作(deg:图论中点连出的边数)。而在邻接矩阵中,我们需要进行|V|步操作。但是在当我们需要插入或者删除一个邻接与v的节点的时候,我们需要对邻接表进行调整,而对于邻接矩阵,只需要进行0和1的变换即可。
-
邻接矩阵的空间复杂度是O(V*V),而邻接表的复杂度为O(V+E),V为顶点数,E为边数。
我们将会遇到的应用使用几乎都是稀疏图——《算法第四版》
在这里我们可以再想一下,在最稠密的情况下(每一个结点都与其他结点相连接),邻接矩阵的空间复杂度会远远的 小于邻接表(n!和n^2不在一个数量级)。
- 如果出现平行边了,我们只能够使用邻接表去表示。
说了这么多,在下面的数据结构中,除非特殊说明,我们选择使用邻接表来进行数据储存。我们可以上代码了。
首先是抽象类的代码:
package graph;
import java.awt.*;
import java.util.ArrayList;
import java.util.List;
/**
* 图的抽象数据结构
* @author xiaohui
*/
public abstract class Graph {
// 顶点数量
int V;
// 边的数量
int E;
// 邻接表
List[] adj;
// 构造一个含有V个顶点的图,但是不含边
Graph(int V) {
adj = new ArrayList[V];
for (int i = 0; i < V; i++) {
adj[i] = new ArrayList();
}
this.V = V;
}
/**
* @return 返回顶点的数量
*/
int V(){
return V;
}
/**
* @return 返回边的数量
*/
int E(){
return E;
}
/**
* 在图中添加一条边v-w
* @param v
* @param w
*/
abstract void addEdge(int v, int w);
/**
* 获得与v相邻的所有顶点
* @param v
* @return
*/
abstract Iterable adj(int v) ;
/**
* 与结点s相连通的所有结点
* @param s
* @return
*/
abstract Iterablesearch(int s);
/**
* 是否存在S结点到V结点的路径
* @param s
* @param v
* @return
*/
abstract boolean hasPathTo(int s,int v);
/**
* 找出s到v结点的路径
* @param s
* @param v
* @return
*/
abstract Iterable pathTo(int s,int v) ;
/**
* 便于进行打印
* @return
*/
@Override
public String toString() {
String s = "Graph{" +
"V=" + V +
", E=" + E +
'}';
for (int v=0;v":");
for (int w :this.adj(v)) {
s += w+" ";
}
s+= "\n";
}
return s;
}
}
大家可能发现,上面的数据结构设计的不是很严谨,比如说结点都是使用了Int数据类型,而没有使用泛型。同样,这些方法不一定全部在一个类中实现,可能会进行分离。
首先让我们来实现较为简单的几个函数。
@Override
void addEdge(int v, int w) {
adj[v].add(w);
adj[w].add(v);
this.E ++;
}
@Override
Iterable adj(int v) {
return adj[v];
}
接下来我们需要实现的就是众所周知的搜索函数了(因为深度优先搜索和广度有限搜索应该算大名鼎鼎的算法了吧)。我们想知道途中有哪一些的点,使用不同的算法会产生不同的作用效果。
深度优先搜索
深度优先搜索类似i走迷宫,一条路走到黑,如果发现这条路走不通,就在前一个路口继续向前走。就像下面这样(图片节选自《算法第四版》)
那么算法中,我们需要解决什么问题呢?我们可以通过adj函数得到结点的相邻结点,但是如果我们如何保证结点已经被我们访问过了,我们就需要一个标志mark,这个标志代表着这个结点是否已经被访问过。(HashSet这种数据结构也可以做到这种事情)。步骤如下:
- 将被访问的结点标记为已访问
- 递归地访问它的所有没有被标记过的邻居结点
/**
* 无向图的深度优先搜索
* @author xiaohui
*/
public class DepthFirstSearch {
private boolean[] marked;
private int count;
public DepthFirstSearch(UndirGraph graph,int s){
marked = new boolean[graph.V()];
dfs(graph,s);
}
private void dfs(UndirGraph graph, int s) {
marked[s] = true;
count++;
for (int v:graph.adj(s)){
if (!marked[v]){
dfs(graph,v);
}
}
}
public boolean getMarked(int w) {
return marked[w];
}
public int getCount() {
return count;
}
}
大家可以有上面的代码可以i很简单的知道,获得与s相同的结点,只需要对dfs进行递归即可,并将结点的marked标志设置为true即可。现在我们就可以完善search函数了。
Iterable search(int s) {
DepthFirstSearch dfs = new DepthFirstSearch(this,s);
List list = new ArrayList(dfs.getCount());
for (int i=0;i<this.V();i++) {
if (dfs.getMarked(i)){
list.add(i);
}
}
return list;
}
在上面的深度优先搜索的算法,其实还有一个应用,那就是寻找路径的问题,也就是说,通过深度优先算法,我们可以知道A结点和X结点是否存在一条路径,如果有,则输出路径。
/**
* @author xiaohui
* 通过深度优先搜索寻找路径
*/
public class DepthFirstSearchPath {
private boolean[] marked;
/**
* 从起点到一个顶点的已知路径上面的最后一个顶点,例如:
* 0-3-4-5-6 则 edgeTo[6] = 5
*/
private int[] edgeTo;
/**
* 起点
*/
private final int s;
/**
* 在graph中找出起点为s的路径
* @param graph
* @param s
*/
public DepthFirstSearchPath(Graph graph,int s) {
marked = new boolean[graph.V()];
this.s = s;
edgeTo = new int[graph.V()];
dfs(graph,s);
}
private void dfs(Graph graph, int s) {
marked[s] = true;
for (int v:graph.adj(s)){
if (!marked[v]){
edgeTo[v] = s;
dfs(graph,v);
}
}
}
/**
* v的顶点是否可达,也就是说是否存在s到v的路径
* @param v
* @return
*/
public boolean hasPathTo(int v){
return marked[v];
}
/**
* 返回s到v的路径
* @param v
* @return
*/
public Iterable pathTo(int v) {
if (!hasPathTo(v)){
return null;
}
Stack path = new Stack<>();
for (int x = v;x!=s;x = edgeTo[x]){
path.push(x);
}
path.push(s);
return path;
}
在上面的算法中, 我们首先进行深度优先遍历将每个结点是否被遍历保存到marked[]数组中,然后,在edgeTo[]数组我们保存了进行深度遍历中被遍历结点的上一个结点,示意图如下图所示(图片节选自《算法》):
现在我们可以补全上文中的一些函数了。
/**
* 是否存在S结点到V结点的路径
* @param s
* @param v
* @return
*/
@Override
boolean hasPathTo(int s, int v) {
DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);
return dfsPath.hasPathTo(v);
}
/**
* 找出s到v结点的路径
* @param s
* @param v
* @return
*/
@Override
Iterable pathTo(int s, int v) {
DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);
return dfsPath.pathTo(v);
}
通过深度优先搜索,我们可以得到s结点的路径,那么深度优先搜索还有什么用法呢?其中有一个用法就是寻找出一幅图的所有连通分量。
public class CC {
private boolean[] marked;
/**
* id代表结点所属的连通分量为哪一个,例如:
* id[1] =0,id[3]=1
* 代表1结点属于0连通分量,3结点属于1连通分量
*/
private int[] id;
/**
* count代表连通分量的表示,0,1……
*/
private int count;
public CC(Graph graph) {
marked = new boolean[graph.V()];
id = new int[graph.V()];
for (int s=0;sif (!marked[s]){
count++;
dfs(graph,s);
}
}
}
private void dfs(Graph graph,int v) {
marked[v] = true;
id[v] = count;
for (int w:graph.adj(v)) {
if (!marked[w]){
dfs(graph,w);
}
}
}
/**
* v和w是否属于同一连通分量
* @param v
* @param w
* @return
*/
public boolean connected(int v,int w){
return id[v]==id[w];
}
/**
* 获得连通分量的数量
* @return
*/
public int getCount() {
return count;
}
/**
* 结点属于哪一个连通分量
* @param w
* @return
*/
public int id(int w){
return id[w];
}
}
在下图中,有三个连通分量。
说完深度优先搜索,我们可以来说一说广度优先搜索算法了。在前面的深度优先搜索中,我们将深度优先搜索算法比喻成迷宫,它可以带我们从一个结点走到另外一个结点(也就是寻找路径问题),但是如果我们需要去解决最短路径的问题,使用深度优先搜索能不能解决呢?答案是不能,我们可以想一想,使用深度优先搜索,我们是一条道走到“黑”,有可能离开始结点最近的结点反而还有可能最后遍历。但是广度优先遍历却可以解决这个问题。
广度优先遍历
广度优先的算法在迷宫中类似这样:我们先遍历开始结点的相邻结点并将结点,然后按照与起点的距离的顺序来遍历所有的顶点。在前面的深度优先遍历中,我们使用了隐式的栈【LIFO】(递归)来进行保存结点,而在广度优先遍历中,我们将使用显式的队列(FIFO)来保存结点。
进行广度优先遍历的算法步骤如下:
先将起点加入队列,然后重复以下步骤:
- 取队列中的下一个顶点v并标记它
- 将与v相邻的所有未被标记过的结点加入队列
package graph.undir;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
/**
* @author xiaohui
* 广度优先遍历
*/
public class BreadthFirstSearch {
private boolean[] marked;
private final int s;
private int[] edgeTo;
public BreadthFirstSearch(Graph graph,int s) {
this.s = s;
this.marked = new boolean[graph.V()];
this.edgeTo = new int[graph.V()];
bfs(graph,s);
}
private void bfs(Graph graph, int s) {
Queue queue = new LinkedList<>();
marked[s] = true;
// 将s加入队列中
queue.offer(s);
while(!queue.isEmpty()){
// 从队列中删除结点
int v = queue.poll();
for (int w: graph.adj(v)) {
if (!marked[w]){
edgeTo[w] = v;
marked[w] = true;
queue.offer(w);
}
}
}
}
public boolean hasPathTo(int v){
return marked[v];
}
public Iterable pathTo(int v) {
if (hasPathTo(v)){
return null;
}
Stack path = new Stack<>();
for (int i = v; i != s; i = edgeTo[i]) {
path.push(i);
}
path.push(s);
return path;
}
}
对于从s可达的任意顶点v,广度优先搜索都能找到一条从s到v的最短路径。下面是进行广度优先遍历的情况图:
在这里我们可以思考一下如何使用广度优先搜索或者深度优先搜索解决这两个问题:
- 图G是无环图吗?(假设不存在自环或者平行边)
- 图G是二分图吗?
在上面两个问题的解决方法很简单。
第一个问题中,我们可以这样思考:在进行搜索的时候,如果A结点的邻居结点B已经被被标记了,但是如果在B结点中,它的邻居结点C已经被标记了,但是如果邻居结点C并不是结点A,那么这幅图就是一个有环图。道理很简单,在前面我们知道,通过一个已经被标记的结点,我们肯定可以通过该节点回到起点s,那么C结点有一条路径回到起点,A结点也有一条路径回到起点,而B结点将A和C结点连接起来了,形成了一个环。
第二个问题中,和第一个问题很类似,在C结点中,如果C结点的颜色不和A结点一样(则和B结点一样),那么该图一定不会是一个二分图。
有向图
在有向图中,边是单边的,也就是说,边是由一个结点指向另外一个结点, 两个结点的邻接性是单向的。在一幅有向图中,一个顶点的出度为该顶点指出的边的总数,入度为指向该顶点的边的总数。在一幅有向图中间存在4种关系:
A->B,A<-B,A B(没有边相连接),A->B A<-B
- 有向路径:由一系列顶点组成,对于其中的每一个顶点都存在一条有向边从它指向序列中的下一个顶点。
- 有向环: 为一条至少含有一条边且起点和终点相同的有向路径。
有向图详解
在有向图中,对代码需要进行一些改变,在addEdgeo函数中,我们不再是添加2条边,而是只是添加一条边,同时我们添加了一个reserve函数,目的是将边的方向进行翻转。
/**
* 在图中添加一条边v-w
* @param v
* @param w
*/
@Override
void addEdge(int v, int w) {
adj[v].add(w);
E++;
}
/**
* 遍历每一个结点,然后进行翻转
* @return 返回翻转后的图
*/
public DiGraph reverse(){
DiGraph diGraph = new DiGraph(V);
for (int i = 0; i < V; i++) {
for (int w:adj(i)){
diGraph.addEdge(w,i);
}
}
return diGraph;
}
/**
* 获得与v相邻的所有顶点
*
* @param v
* @return
*/
@Override
Iterableadj(int v) {
return adj[v];
}
路径问题
上面的代码还是比较简单的。在无向图中,我们研究了结点的可达性,使用深度优先算法来探究两个结点是否可达,而在有向图中,单点可达性:是否存在一条从s到达给定顶点v的有向路径。
/**
* @author xiaohui
* 有向图的深度优先算法
*/
public class DirectGraphDFS {
private boolean[] marked;
/**
* 有向图的深度优先算法构造函数
* @param diGraph
* @param s 起点
*/
public DirectGraphDFS(DiGraph diGraph,int s) {
marked = new boolean[diGraph.V()];
dfs(diGraph,s);
}
/**
* 深度递归算法
* @param diGraph
* @param v
*/
private void dfs(DiGraph diGraph, int v) {
marked[v] = true;
for (int w:diGraph.adj(v)) {
if (!marked[w]){
dfs(diGraph,v);
}
}
}
/**
* 起点s可达到v吗
* @param v
* @return
*/
public boolean pathTo(int v){
return marked[v];
}
}
在一文看懂javaGC这篇博客中,我们讨论了在Java虚拟机中,我们使用了可达性分析算法来判断一个对象是否已经死亡。在下图中灰色的方块代表的是可以被回收的对象。
同样,在无向图中,我们可以通过搜索来找出结点之间的路径,以及通过广度优先搜索来找出最短路径,同样,在有向图中我们同样能够做到这样。同样,在算法中,和前面的无向图之间的算法一毛一样,没什么改变。
调度问题
调度问题说起来很简单,就是先有鸡还是先有蛋的问题。一种应用广泛的模型就是给定一组任务并安排它们的执行顺序,其中顺序会有限制条件去限制(例如任务的执行的开始时间,也可能是任务的时耗)。其中最重要的一种条件叫优先级限制。
在优先级限制中,明确的指明了哪些任务必须在哪些任务之前完成,在有向图中,优先级限制下的调度问题等价于下面的问题:
拓扑排序:给定一幅有向图,将所有的顶点排序, 使得所有的有向边均从排在前面的元素指向排在后面的元素(或者说明无法做到这一点)
在下面的图是一个有向图进行拓扑排序后的结果。
在前面我们说了,必须明确任务的先后关系,那么如果如果任务关系形成了环状,比如说A要在B之前完成,B要在C之前完成,但是C要在A之前完成, 那么这个问题肯定是无解的。so,我们在进行拓扑排序之前得先判断有向图中间是否有环。(也就是说优先级限制下的调度问题等价于计算有向无环图的所有a丁丁的拓扑排序)
/**
* 查找有向图中是否存在环
* @author xiaohui
*/
public class DirectedCycle {
private boolean[] marked;
private int[] edgeTo;
/**
* 有向环中所有顶点
*/
private Stack cycle;
/**
* 顶点是否在递归调用栈上
*/
private boolean[] onStack;
public DirectedCycle(Graph graph) {
onStack = new boolean[graph.V()];
edgeTo = new int[graph.V()];
marked = new boolean[graph.V()];
for (int v=0;vif (!marked[v]){
dfs(graph,v);
}
}
}
private void dfs(Graph graph, int v) {
onStack[v] = true;
marked[v] = true;
for (int w:graph.adj(v)){
if (this.hasCycle()){
return;
}
else if(!marked[w]){
edgeTo[w] = v;
dfs(graph,w);
}
// 当它的邻居结点已经被标记时,且在同一个调用栈中。
else if (onStack[w]){
cycle = new Stack<>();
for (int x= v;x != w;x = edgeTo[x]){
cycle.push(x);
}
cycle.push(w);
cycle.push(v);
}
onStack[v] = false;
}
}
/**
* 有向图中是否含有环
* @return
*/
public boolean hasCycle(){
return cycle == null;
}
/**
* 获得有向环中的顶点
* @return
*/
public Iterable cycle(){
return this.cycle;
}
}
在这里我将着重解释下onStack这个数组的作用。我们可以回想一下我们在无向图中如果查找一个图中是否存在一个环:我们通过查看结点的下一个结点是不是被标记的来判断的。之所以这样因为无向图是双向导通的,我们必然可以根据被标记的点回去,但是我们想想,有向图可以吗?显然是不行的,因为有向图是单向导通的。我们并不能通过已经被标记的结点又回到起点。因此,onStack的作用就在与这个地方。当某结点A的邻居结点的onStack为true的时候,说明该邻居结点结点正处于递归的过程中,则该邻居结点能够通过递归得到结点A。而当onStack为false的时候则说明改邻居结点不能通过递归回到回到结点A。
说完有向图中间的环的检测方法,我们就可以来讨论一下如何对有向图的顶点进行拓扑排序了。
实际上深度优先搜索也算得上是一种拓扑排序。在深度优先搜索中,我们能够保证每个顶点的访问顺序必定会符合拓扑排序的规律。根据递归的情况,下面有3中排序的规律:
- 前序:在递归调用之前将顶点加入队列
- 后序:在递归调用之后将顶点加入队列
- 逆后序:在递归调用之后将顶点压入栈
有向图中基于深度优先搜索的顶点排序:
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
/**
* 深度递归顶点排序
* @author xiaohui
*/
public class DfsOrder {
private boolean[] marked;
/**
* 前序
*/
private Queue pre;
/**
* 后序
*/
private Queue post;
/**
* 逆后序
*/
private Stack reversePost;
public DfsOrder(Graph graph) {
this.marked = new boolean[graph.V()];
this.pre = new LinkedList<>();
this.post = new LinkedList<>();
this.reversePost = new Stack<>();
for (int i = 0; i < graph.V(); i++) {
if(!marked[i]){
dfs(graph,i);
}
}
}
private void dfs(Graph graph, int v) {
pre.offer(v);
marked[v] = true;
for (int w:graph.adj(v)) {
if (!marked[w]){
dfs(graph,w);
}
}
post.offer(v);
reversePost.push(v);
}
public Iterable reversePost() {
return this.reversePost;
}
}
而在其中逆后序排序便是拓扑排序了。
强连通性
我们已经知道在有向图中,边是单向的。但是如果两个顶点是互相可达(类似无向图)的,就称他们为强连通的。如果一幅图中的任意两个顶点都是强连通的,就称这幅图是强连通的。
两个顶点是强连通的当且尽当它们都在一个普通的有向环中。:很简单的解释,在环中,两个结点都是互相可达的。
连通性有下面3个性质:
- 自反性:任意顶点和自己是强连通的
- 传递性:v和w是强连通,w和x是强连通的,则v和x是强连通的
- 对称性:v和w是强连通,则w和v也是强连通的。
强连通分量:
下面是一张有向图和它的强连通分量。每一个阴影块就是就是一个强连通分量。
以高中的生物知识来说,上面就是一个生态系统的能量流通图,在某些生物之间能量可以相互流通,这样就是一个强连通分量了,但是对于某些来说,只有对于生态系统只有输出并不会得到输入(比如说阳光),而有些只有输入没有输出(消费者? 不确定对不对,高中知识有点忘了)。
接下来我们需要去寻找强连通分量了。在无向图中,我们计算连通分量仅仅是在dfs算法中加了区区几行代码便完美地解决了连通分量的问题。那么,我们在有向图中应该怎么做呢?
在这里我们可以思考一下我们前面所说的强连通性的规律,以及我们在调度问题中如何检测环的算法来解决这个问题。
在这里有一个比较暴力的解决方法,对于某个结点V,我们得到在有向图中V可以到达的顶点,然后进行遍历,得到可到达V的顶点。然后呢,我们取他们的交集。这样就可以得到连通分量了。但是显而易见,这样的时间复杂度是O(n2)。找出可到达V的顶点的时间复杂度是O(n2),取并集的时间复杂度视数据结构而定,使用数组的话时间复杂度是O(n^2)。
总所周知,一般我们是不接受平方级别的时间复杂度的(比如说排序),而在无向图中,获得连通分量的时间复杂度仅仅为O(n),那么在有向图中间我们的解法是否可以像无向图一样美妙呢?
有一个算法叫做Kosaraju,非常的简洁,让我们来说一说这个算法的步骤,然后再来讨论它为什么要这样做?
- 将一幅图G进行反向也就是调用reverse()函数得到G2
- 将G2进行拓扑排序得到它的逆后序排序(也就是一个序列)。
- 然后对图进行深度优先搜索,进行深度搜索的顺序就是第2个步骤中的逆后序序列。
- 在构造函数中,使用同一个dfs()函数调用被访问的顶点都在同一个强连通分量中间。
接下来是代码,大家会发现,代码特别的少
/**
* 使用Kosaraju算法的得到强通分量
* @author xiaohui
*/
public class DfsSCC {
private boolean[] marked;
private int[] id;
private int count = 0;
public DfsSCC(DiGraph graph) {
marked = new boolean[graph.V()];
id = new int[graph.V()];
DfsOrder order = new DfsOrder(graph.reverse());
for (int s:order.reversePost()){
dfs(graph,s);
count++;
}
}
private void dfs(DiGraph graph, int v) {
marked[v] = true;
id[v] = count;
for (int w:graph.adj(v)){
if (!marked[v]){
dfs(graph,w);
}
}
}
/**
* 返回某结点强连通的id
* @param v
* @return
*/
public int id(int v){
return id[v];
}
/**
* 判断v和w是否属于强连通
* @param v
* @param w
* @return
*/
public boolean stronglyConnected(int v,int w){
return id[v]==id[w];
}
/**
* 返回强连通分量的数目
* @return
*/
public int cout(){
return count;
}
}
上面便是寻找强连通分量的代码,接下来我们要好好的思考一下为什么能够达到这种效果。
首先我们可以很很简单的知道,每个和s强连通的顶点v都会在构造函数dfs(graph,s)被访问到。接下来我们需要思考的是,为什么构造函数中dfs(graph,s)函数所到达的任意顶点v都必然是和s强连通的。
设v是dfs(graph,s)达到的某个顶点,那么原图G中必然会有一条s到v
的路径,现在我们只需要找到v到s
的路径即可。等价于证明G2(G通过reverse()函数得到)有一条s到v
的路径。
在这里我们可以想一想,v结点在拓扑排序中会不会出现在s结点的前面?当然不会!!(如果出现在前面,在dfs(graph,s)中就不会调用dfs(graph,v),因为v结点已经被标记了。)
因此现在我们已经确定了v结点在s结点的后面, 那么代表着什么呢?代表着在G2的深度优先遍历中,dfs(graph,v)调用结束绝逼在dfs(graph,s)之前调用(栈是先进后出),那么在图G2中就分为两种情况:
- dfs(graph,v)在dfs(graph,s)调用之前结束
- dfs(graph,v)在dfs(graph,s)调用结束之前结束
因为在图G中有一条s->v的路径,在图G2中有一条v->s的路径,则第一种情况不可能出现。则第二种情况说明了G2中有一条s->v的路线。则图G中有一条v->s的路径。
下面是一张过程示意图(左边是对G2进行逆后序排序,右边是根据排序的结果进行深度递归)
最小生成树(无向图)
在说最小生成树之前,我们得先来说说加权图。下图中便是一副加权无向图。加权图和图的区别在于它的每一条边都有一个权值,那么它有什么用呢?举个栗子:图我们可以应用在网络流量方面,那么每一条边的h权值可以代表某一时刻的网络质量,那么当流量进行选择的时候,肯定会选择质量好的那一路。(实际上网络流量选择要比这还复杂,因为还要考虑到负载均衡的情况。)
那么什么是最小生成树呢?
图的生成树是它的一棵含有其所有顶点的无环连通子图。
一幅加权图的**最小生成树(MST)**是它的一棵权值(树的所有边的权值之和)最小的生成树。如上图的黑色边构成的无环连通子图。在这里我们规定:
- 只考虑连通图:试想一下,如果不连通,我们又如何知道两个顶点之间的权值呢?
- 边的权重:边的权值可以为正数,负数,0
- 所有边的权只能都各不相同:相同的话,最小生成树就不唯一了
下面是生成树的一些性质:
- 用一条边连接树中的任意两个顶点都会产生一个新的环。
- 从树中任意删除一条边都将会得到两棵独立的树。
如下图:
根据上面的两个性质,我们可以将图中所有的顶点切分为两个非空且不重叠的两个集合。而其中横切边是一条连接两个属于不同集合的顶点的边。
切分定理:把加权图中的所有顶点分为集合、检查横跨两个集合的所有边并识别哪条边应属于图的最小生成树。
当然,在切分中,我们会得到一条权重最小的边(这条边必然属于最小生成树的边),但是并不代表着其它的边就不属于最小生成树。
最小生成树的贪心算法
切分定理是解决最小生成树问题的所有算法的基础。而这些算法都是贪心算法的特殊情况:使用切分定理找到一条边,然后不断的切分,直到找出所有的最小生成树的所有边。
最小生成树的贪心算法:我们将含有V个顶点的加权连通图的最小生成树的边标记为黑色(初始状态边是灰色的),找到一种切分,它产生的横切边均不为黑色,然后权重最小的横切变标记为黑色。反复,直到标记了V-1条黑色边为止。
下面是一个贪心最小生成树算法的图:
因为有权无向图的边发生了改变,所以定义数据结构的代码也得发生改变。
加权无向图的数据结构
带权重的边的数据类型
/**
* 定义一条边的数据类型结构
*/
public class Edge implements Comparable<Edge> {
/**
* 一条边的某一个顶点
*/
private final int v;
/**
* 一条边的另外一个顶点
*/
private final int w;
/**
* 边的权重
*/
private final double weight;
public Edge(int v, int w, double weight) {
this.v = v;
this.w = w;
this.weight = weight;
}
public double weight(){
return weight;
}
/**
* 得到边的某一个顶点
* @return
*/
public int either(){
return v;
}
/**
* 通过某一个顶点得到边的另外一个顶点
* @param vertex
* @return
*/
public int other(int vertex){
if(vertex == w){
return v;
}else if(vertex==v){
return w;
}else{
throw new RuntimeException("没有这一条边");
}
}
/**
* 边进行比较
* @param o
* @return
*/
@Override
public int compareTo(Edge o) {
if (this.weight() > o.weight()){
return 1;
}else if (this.weight() < o.weight()){
return -1;
}
return 0;
}
@Override
public String toString() {
return "Edge{" +
"v=" + v +
", w=" + w +
", weight=" + weight +
'}';
}
}
加权无向图的数据类型:
import java.util.ArrayList;
import java.util.List;
/**
* 加权无向图的数据结构
*/
public class EdgeWeightedGraph {
/**
* 顶点总数
*/
private final int V;
/**
* 边的总数
*/
private int E;
/**
* 边
*/
private List[] adj;
public EdgeWeightedGraph(int V)
{
this.V = V;
this.E = 0;
adj = new ArrayList[V];
for (int i = 0; i < V; i++) {
adj[i] = new ArrayList();
}
}
public int V() {
return V;
}
public int E() {
return E;
}
public void addEdge(Edge e) {
int v = e.either(), w = e.other(v);
adj[v].add(e);
adj[w].add(e);
E++;
}
public Iterable adj(int v) {
return adj[v];
}
/**
* 获取图中的所有边
* @return
*/
public Iterable edges() {
List list = new ArrayList<>();
for (int i = 0; i < V; i++) {
for (Edge e:adj[i]){
/**
* 如果i和j为一条边e,那么adj[i] = e;adj[j] = e;这两条边是一样的,所以我们需要去除一条边
*/
if (e.other(i)>i){
list.add(e);
}
}
}
return list;
}
}
在定义好数据结构后,我们就可以开始来说一下生成最小树的算法了
最小生成树的算法
对于最小生成树有两种常用的算法,普里姆算法(Prim算法)和克鲁斯卡尔算法(Kruskal算法)。这两种算法都是基于贪心算法的算法。首先让我们来说一下Kruskal算法,这个比较简单。
Kruskal算法
Kruskal算法很简单,首先我们得到所有的边,然后根据边的权重对边进行排序(从小到大)。然后我们将边根据顺序加入最小生成树中(必须保证加入的边不会与已经加入的边构成环)
现在这个问题就分成了两个部分:
- 如何排序——使用排序算法即可(使用堆排序),使用优先队列
- 如何检测回路。
我们来着重讨论第二点,如何检测回路
如何检测回路,我们可以使用union-find算法。首先我们说一下这个的原理:
首先我们有N个独立分散的点,如果我们将点用线段进行连接,如何避免成环。我们可以这样想,,像树一样,有根节点,如果两个结点的根节点是一样的,那么毋庸置疑,将两个结点进行连接肯定会成环。
其中,这个算法有3种写法:
- quick-find算法。
- quick-union算法。
- 加权quick-union算法
我将介绍加权quick-union算法,因为这个在最最坏的情况下时间复杂度也只有lgN。
quick-union算法
/**
* 加权quick-union算法
*/
public class WeightQuickUnionUF {
/**
* 结点的父节点
*/
private int[] id;
/**
* (由结点索引的)各个根节点所对应的根节点的大小
*/
private int[] sz;
/**
* 连通分量的数量
*/
private int count;
/**
* 进行初始化,初始化后其中每一个结点都是一个连通分量
* 其中结点的父节点为自己本身
* @param N
*/
public WeightQuickUnionUF(int N) {
count = N;
id = new int[N];
for (int i = 0; i < N; i++) {
id[i] = i;
}
sz = new int[N];
for (int i = 0; i < N; i++) {
sz[i] = 1;
}
}
/**
* p和q是否相链接,若相连接,则在同一个连通分量里面
* @param p
* @param q
* @return
*/
public boolean connected(int p,int q){
return find(p) == find(q);
}
/**
* 找到根节点
* @param v
* @return
*/
private int find(int v) {
// 在根节点中id[v]= v(在初始化的时候定义的)
while(v != id[v]){
v = id[v];
}
return v;
}
/**
* 在p和q之间添加一条链接
* @param p
* @param q
*/
public void union(int p,int q){
int i = find(p);
int j = find(q);
// 如果是同一条连通分量,则返回,没必要添加
if (i==j){
return ;
}
// 这一步的目的是将小树加入大树
if (sz[i]else{
id[j] = i;
sz[i] += sz[j];
}
count --;
}
}
上面的算法在union中,是将小树加入大树。目的是减少在最坏情况下的时间复杂度。
下面便是Kruskal算法的实现部分
package graph.weight;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.PriorityQueue;
import java.util.Queue;
public class KruskalMST {
/**
* 优先队列
*/
private PriorityQueue pq;
/**
* 最小生成树
*/
private Queue mst;
public KruskalMST(EdgeWeightedGraph graph) {
pq = new PriorityQueue<>();
mst = new LinkedList<>();
// 将边加入优先队列
for (Edge edge:graph.edges()){
pq.add(edge);
}
mst(graph);
}
private void mst(EdgeWeightedGraph graph) {
WeightQuickUnionUF uf = new WeightQuickUnionUF(graph.V());
while(!pq.isEmpty()){
// 从里面取出最小的元素
Edge e = pq.poll();
int v = e.either();
int w = e.other(v);
// 防止成环
if (uf.connected(v,w)){
continue ;
}
uf.union(v,w);
mst.offer(e);
}
}
public Queue getMst() {
return mst;
}
}
ok,说完Kruskal算法,让我们来说一说Prim算法。
Prim算法
prim算法和kruskal算法一样,同样使用的是贪心算法。它的原理很简单,就是每一步为都会为一棵生长中的树添加一条边(总是将连接树中的顶点与不在树中的顶点切权重最小的边加入树中)。
下面是一个示例:
其中prim算法有两种实现方式:
-
延时实现
- 把一个顶点加入最小生成树中。
- 再把它的所有的连接着未访问顶点的邻接边都放入优先队列(作为横切边)。
- 然后从横切边中弹出权重最小的一条,检查它两边的顶点是否在最小生成树中,如果在,则忽略该边,从头开始步骤3。如果不在,则把它和它所连接的顶点加入最小生成树中。
- 再对它所连接的顶点作和以上相同的操作。直到优先队列为空。
下面是延时实现的代码:
import java.util.LinkedList; import java.util.PriorityQueue; import java.util.Queue; /** * prim算法延时实现 */ public class LazyPrimMST { private boolean[] marked; private Queue
mst; /** * 横切边 */ private PriorityQueue pq; public LazyPrimMST(EdgeWeightedGraph graph){ marked = new boolean[graph.V()]; pq = new PriorityQueue<>(); mst = new LinkedList<>(); mst(graph); } /** * 生成最小树 * @param graph */ public void mst(EdgeWeightedGraph graph){ visit(graph,0); while (!pq.isEmpty()){ // 从优先队列中得到最小的边 Edge e= pq.poll(); int v = e.either(); int w = e.other(v); // 如果两个顶点都被标记了,则看下一条边 if (marked[v] && marked[w]){ continue; } // 将边加入mst mst.add(e); if (!marked[v]){ visit(graph,v); } if (!marked[w]){ visit(graph,w); } } } /** * 标记顶点v并将其(所有)边(边所相连接的另外一个顶点未被标记)加入队列中 * @param graph * @param v */ public void visit(EdgeWeightedGraph graph,int v){ marked[v] = true; for (Edge e:graph.adj(v)){ // 另外一个顶点 if (!marked[e.other(v)]){ // 将顶点假如优先队列 pq.add(e); } } } /** * 返回最小生成树 * @return */ public Queue getMst() { return this.mst; } }
当时大家可能发现了一个问题,那就是在优先队列中保存了很多没有用的边,无疑,这些边是需要占用空间的,那么如果我们去除这些失效的边,是不是就可以节约空间了呢? 于是便又有了下面的算法。
-
即时实现
- 先把一个顶点放入最小生成树中。
- 遍历该顶点的邻接边结点(结点A),如果边(边W)所连接的某个顶点(结点B)不在最小生成树中,且它(结点B)到该顶点(结点A)的距离大于该边(边W)的权重,则该结点(结点B)到顶点(结点A)的距离变成该边的权重,并且更新索引优先队列中的边。
- 从索引优先队列弹出权重最小的边,然后对它所连接的顶点作以上操作,直到栈空。
import java.util.ArrayList; import java.util.HashMap; import java.util.PriorityQueue; /** * 即时版本的prim算法 */ public class PrimMST { /** * 某结点距离树最近的边 */ private Edge[] edgeTo; /** * 某结点距离树的权重距离 */ private double[] distTo; /** * 结点是否在树中 */ private boolean[] marked; /** * 有效的横切边(也就是被保存在优先队列中有效的边) * key: 结点的id * value:权重 */ private IndexMinPQ
pq; public PrimMST(EdgeWeightedGraph graph) { edgeTo = new Edge[graph.V()]; distTo = new double[graph.V()]; marked = new boolean[graph.V()]; // IndexMinPQ是索引优先队列,并不是Java本身的库,在我的github库中有这个IndexMinPQ pq = new IndexMinPQ<>(graph.V()); // 初始化结点到树的距离为无限大 for (int i = 0; i < graph.V(); i++) { distTo[i] = Double.POSITIVE_INFINITY; } pq.insert(0,0.0); while(!pq.isEmpty()){ visit(graph,pq.delMin()); } } private void visit(EdgeWeightedGraph graph, int v) { marked[v] = true; // 遍历与v相连的结点 for (Edge e:graph.adj(v)){ int w = e.other(v); // 假如w已经为最小树的结点,则不进行处理 if (marked[w]){ continue; } // 假如w结点的权重小于w结点到树的距离 if (e.weight() // w结点到最小树的边为e edgeTo[w] = e; // 距离为e边的权重 distTo[w] = e.weight(); // 将结点放入优先队列 if (pq.contains(w)){ pq.changeKey(w,distTo[w]); } else{ pq.insert(w,distTo[w]); } } } } /** * 返回最小生成树 * @return */ public ArrayList mst() { ArrayListlist = new ArrayList<>(); for (Edge e:edgeTo) { if (e!=null){ list.add(e); } } return list; } } 即时的prim算法和延时的prim算法很相似,原理上面并没有什么发生改变,只不过在即时的prim算法中,我们只将有用的边假如优先队列,而没有用的边就不管它了。这样我们就节约了空间,以及遍历边的时间。
性能特点:(V个顶点E条边)
算法(最坏情况下) 空间 时间 Kruskal算法 E ElogE 延时的Prim算法 E ElogE 即时的Prim算法 V ElogV
最短路径
最短路径问题是一个很常见的问题,因为导航地图,流量负载互动都会遇到它。这个我们等几天更新后再讨论……
参考书籍:
《算法》——圣书
再如何不可思议的事情,一旦做的次数多了,便会习惯直至麻木甚至开始乐在其中。 ——将夜