首页 > 代码库 > 图类算法总结
图类算法总结
在开始各类图算法之前,先将图的结构进行分类。
图的表示,在实际实现过程中,有以下几种基本的方式可以来表示图。
1) 邻接矩阵:对于较小或者中等规模的图的构造较为适用,因为需要V*V大小的空间。
2) 边的数组:使用一个简单的自定义edge类,还有两个变量,分别代表边的两个端点编号,实现简单,但是在求每个点的邻接点的时候实现较为困难。
3) 邻接表数组:较为常用,使用一个以顶点为索引的数组,数组每个元素都是和该顶点相邻的顶点列表,这种数组占空间相对于邻接矩阵少了很多,并且能很好的找到某个给定点的所有邻接点。
按照图中边的方向将图分成有向图和无向图:
1)无向图:图中的边没有方向。
2)有向图:图中的边有方向。
对于有向图和无向图的具体实现表示可以使用前面介绍的三种方法,两种图在表示的时候大部分的实现代码都是一致的。
普通无向图的邻接数组表示方法的具体实现代码:
public class Graph {
private int V; //图中的顶点数目
private int E; //图中的边数目
private List<Integer>[] adj; //邻接数组
private int[][] a; //邻接矩阵
public CreatGraph(int V) {
this.E = 0;
this.V = V;
adj = new ArrayList[V];
a=new int[V][V];
for (int i = 0; i < V; i++)
adj[i] = new ArrayList<>();
}
//由于无向图中的边是没有方向的,所以添加边的时候需要在边的两个顶点对应的邻接列表中都添加顶点信息。
public void addEdge(int v1, int v2) {
a[v1][v2]=1;
a[v2][v1]=1;
adj[v1].add(v2);
adj[v2].add(v1);
E++;
}
public int V() {
return V;
}
public int E() {
return E;
}
//邻接数组返回给定点的所有邻接点
public List<Integer> adj(int i) {
return adj[i];
}
//邻接矩阵返回给定点的所有邻接点
public List<Integer> adj1(int i){
List<Integer> list=new ArrayList<>();
int[] adj1=new int[V];
adj1=a[i];
for(int v:adg1)
if(v!+0)list.add(v);
return list;
}
}
无权有向图的具体实现代码:
public class DirectedGraph {
private int V; //图中的顶点数目
private int E; //图中的边数目
private List<Integer>[] adj; //邻接数组
private int[][] a; //邻接矩阵
public DirectedGraph(int V) {
this.E = 0;
this.V = V;
adj = new ArrayList[V];
a=new int[V][V];
for (int i = 0; i < V; i++)
adj[i] = new ArrayList<>();
}
//由于无向图中的边是有方向的,所以添加边的时候需要只需要在起始点的邻接列表中添加顶点信息。
public void addEdge(int v1, int v2) {
a[v1][v2]=1;
adj[v1].add(v2);
E++;
}
public int V() {
return V;
}
public int E() {
return E;
}
//邻接数组返回给定点的所有邻接点
public List<Integer> adj(int i) {
return adj[i];
}
//邻接矩阵返回给定点的所有邻接点
public List<Integer> adj1(int i){
List<Integer> list=new ArrayList<>();
int[] adj1=new int[V];
adj1=a[i];
for(int v:adg1)
if(v!+0)list.add(v);
return list;
}
}
一 图的遍历算法:
介绍两种比较基础的图遍历算法,广度优先搜索和深度优先搜索。
1)深度优先搜索:这是一种典型的递归算法用来搜索图(遍历所有的顶点);
思想:从图的某个顶点i开始,将顶点i标记为已访问顶点,并将访问顶点i的邻接列表中没有被标记的顶点j,将顶点j标记为已访问,并在访问顶点j的邻接列表中未被标记的顶点k依次深度遍历下去,直到某个点的所有邻接列表中的点都被标记为已访问后,返回上层。重复以上过程直到图中的所有顶点都被标记为已访问。
深度优先遍历和树的先序访问非常类似,尽可能深的去访问节点。深度优先遍历的大致过程(递归版本):
a)在访问一个节点的时候,将其设置为已访问。
b)递归的访问被标记顶点的邻接列表中没有被标记的所有顶点
(非递归版本):
图的非递归遍历我们借助栈来实现。
a)如果栈为空,则退出程序,否则,访问栈顶节点,但不弹出栈点节点。
b)如果栈顶节点的所有直接邻接点都已访问过,则弹出栈顶节点,否则,将该栈顶节点的未访问的其中一个邻接点压入栈,同时,标记该邻接点为已访问,继续步骤a。
该算法访问顶点的顺序是和图的表示有关的,而不只是和图的结构或者是算法有关。
深度优先探索是个简单的递归算法(当然借助栈也可以实现非递归的版本),但是却能有效的处理很多和图有关的任务,比如:
a) 连通性:ex:给定的两个顶点是否联通 or 这个图有几个联通子图。
b) 单点路径:给定一幅图和一个固定的起点,寻找从s到达给定点的路径是否存在,若存在,找出这条路径。
寻找路径:
为了实现这个功能,需要在上面实现的深度优先搜索中中增加实例变量edgeTo[],它相当于绳索的功能,这个数组可以找到从每个与起始点联通的顶点回到起始点的路径(具体实现的思路非常巧妙: 从边v-w第一次访问w的时候,将edgeTo[w]的值跟新为v来记住这条道路,换句话说,v-w是从s到w的路径上最后一条已知的边,这样搜索结果就是一条以起始点为根结点的树,也就是edgeTo[]是个有父链接表示的树。)
深度优先搜索的递归实现版本和非递归版本(递归是接住了递归中的隐藏栈来实现的。非递归,借助栈实现)
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class DepthFirstSearch {
//用来记录顶点的标记状态 true表示为已访问,false表示为未被访问。
private boolean[] marked;
private int count;
//用来记录顶点索引所对应的父结点,假设遍历是从s到达的t那么edgeTo[s所对的所用]=t;
private int[] edgeTo;
//起始点
private int s;
private Deque<Integer> dq=new Deque<>();
public DepthFirstSearch(Graph G, int s) {
marked = new boolean[G.V()];
edgeTo = new int[G.V()];
this.s = s;
dq.push(s);
dfs(G, s);
}
//递归形式实现
public void dfs(Graph G, int s) {
marked[s] = true;
count++;
for (int temp : G.adj(s))
if (!marked[temp]) {
edgeTo[temp] = s;
dfs(G, temp);
}
}
//非递归形式实现
private void dfs(Graph G){
while(!dp.isEmpty()){
s=dp.peek();
needPop=true;
marked[s] = true;
for (int temp : G.adj(s))
if (!marked[temp]) {
dp.push(temp);
edgeTo[temp] = s;
needPop=false;
break;
}
}
if(needPop)
dp.pop();
}
public boolean hasPathTo(int v) {
return marked[v];
}
public List<Integer> pathTo(int v) {
if (hasPathTo(v))
return null;
List<Integer> list = new ArrayList<>();
v = edgeTo[v];
while (v != s) {
list.add(v);
v = edgeTo[v];
}
list.add(s);
Collections.reverse(list);
return list;
}
public int count() {
return count;
}
public static void main(String[] args){
int V = 0,E = 0;
Graph G=new Graph(V,E);
int s=0;
DepthFirstSearch dfs=new DepthFirstSearch(G,s);
for(int v=0;v<G.V();v++){
if(dfs.hasPathTo(v))
for(int x:dfs.pathTo(v))
if(x==s)
System.out.print(x);
else
System.out.print("-"+x);
System.out.println();
}
}
}
已经使用DFS解决了一些问题,DFS其实还可以解决很多在无向图中的基础性问题,譬如:
1)计算图中的连通分支的数量;
public class ConnectComponent {
private boolean[] marked;
private int[] id; //标记结点所在的连通分支编号
private int count; //计算连通分支的个数
public ConnectComponent(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++;
}
}
}
public void dfs(Graph G, int s) {
marked[s] = true;
id[s] = count;
for (int temp : G.adj(s))
if (!marked[temp]) {
dfs(G, temp);
}
}
//判断点v和w是否在一个连通分支中
public boolean connected(int v, int w) {
if (id[v] == id[w])
return true;
else
return false;
}
public int id(int v) {
return id[v];
}
public int count() {
return count;
}
}
2)环检测:检测图中是否有环;
public class CycleDetect {
private boolean[] marked;
private boolean flag;
public CycleDetect(Graph G) {
marked = new boolean[G.V()];
for (int s = 0; s < G.V(); s++) {
if(!marked[s])
dfs(G, s, s);
}
}
public void dfs(Graph G, int s, int initial) {
marked[s] = true;
for (int temp : G.adj(s))
if (!marked[temp]) {
dfs(G, temp, initial);
} else {
if (temp == initial) {
flag = true;
return;
}
}
}
public boolean hasCycle(){
return flag;
}
}
3)二分图判断(双色问题):能否用两种颜色给这个二分图进行着色,也就是说这个图是不是二分图。
public class IsBiagraph {
private boolean[] marked;
private boolean[] color;
private boolean flag=true;
public IsBiagraph(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);
}
}
public void dfs(Graph G, int s) {
marked[s] = true;
for (int temp : G.adj(s))
if (!marked[temp]) {
color[temp]=!color[s];
dfs(G, temp);
} else{
if(color[temp]==color[s])
flag=false;
}
}
public boolean isBiagraph (){
return flag;
}
}
2)广度优先搜索:
前面说过,深度优先搜索得到的路径不仅取决于图的结构,还取决于图的表示以及递归调用的性质,但是如果要求最短的路径(给定图G和起始点s寻找给定点v和s间是否存在路径,如果存在,找出最短的路径),那么使用前面的DFS算法并不能解决该问题,所以出现了广度优先搜索BFS来实现这个目的,广度优先搜索也是其他算法的基础。
在程序中,搜索一幅图的时候会遇到有很多条边都需要被遍历的情况,我们会选择其中一条并将其他边留到以后再继续搜索,在DFS中使用栈结构,使用LIFO的规则来描述,从有待搜索的通道中选取最晚遇到的那个通道,然而在BFS算法中,我们希望按照与起点的距离来遍历所有的顶点,使用FIFO(队列)来进行搜索,也就是搜索最先遇到的那个通道。
BFS:使用一个队列来保存所有已经被标记过的但是其邻接点还未被检查过的顶点,现将顶点加入队列中,然后重复下面的操作,直至队列为空:
1)取队列中的下一个顶点v并标记它
2)将与v相邻的所有的未被标记的顶点加入队列中。
广度优先搜索类似于树的按层遍历
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
public class BreadFirstSearch {
private boolean[] marked;
private int[] edgeTo;
private int s;
public BreadFirstSearch(Graph G, int s) {
marked = new boolean[G.V()];
edgeTo = new int[G.V()];
this.s = s;
bfs(G, s);
}
public void bfs(Graph G, int s) {
Deque<Integer> deque = new ArrayDeque<>();
marked[s] = true;
deque.addFirst(s);
while (!deque.isEmpty()) {
s = deque.removeLast();
for (int temp : G.adj(s))
if (!marked[temp]) {
deque.push(temp);
marked[temp] = true;
edgeTo[temp] = s;
}
}
}
public boolean hasPathTo(int v) {
return marked[v];
}
public List<Integer> pathTo(int v) {
if (hasPathTo(v))
return null;
List<Integer> list = new ArrayList<>();
v = edgeTo[v];
while (v != s) {
list.add(v);
v = edgeTo[v];
}
list.add(s);
Collections.reverse(list);
return list;
}
}
DFS和BFS是两种基础的通用的图搜索算法,在搜索中我们都运用以下方法:
将起始点添加入某个数据结构中,然后重复以下步骤直至数据结构中的所有数据都被清空。
1) 取数据结构的下个数据v并且标记它。
2) 将v所有的相邻的未被标记的顶点加入到数据结构中。
这两种算法每次都扩展一个节点的所有子节点,而不同的是,深度搜索下一次扩展的是本次扩展出来的子节点中的一个,而广度搜索扩展的则是本次扩展的节点的兄弟节点
使用范围:DFS可以迅速的找到一个解,然后利用这个解进行剪枝,而BFS可找到最优解。
将上述的图像数据类型改成有向图就可以实现有向图中的遍历问题。
在有向图中单点的联通问题(即给定的两点是否联通)变成了可达问题(即对于给定的两个是否存在一条有向路)在有向图中,使用完全相同的代码,在就可以在有向图中就可以解决可达问题。
public class DirecedtDFS {
public boolean[] marked;
public DirecedtDFS(DiGraph G, int s) {
marked = new boolean[G.V()];
dfs(G, s);
}
public DirecedtDFS(DiGraph G, Set<Integer> set) {
marked = new boolean[G.V()];
for (int s : set)
if (!marked[s])
dfs(G, s);
}
private void dfs(DiGraph G, int s) {
marked[s] = true;
for (int temp : G.adj[s])
if (!marked[temp])
dfs(G, temp);
}
public boolean marked(int i) {
return marked[i];
}
}
然而在有向图中的环检测就和无向图中不大一样,需要保存某条道路上的所有信息,来判断是否形成有向环。
在执行dfs(G,s)的时候查找的总是一条由起点到达s的有向路径,要保存这条路径,程序实现的时候维护了一个由顶点索引的boolean类型数组onStack用来标记递归调用栈上的所有顶点(在dfs调用的时候,将onStack[s]设置成true,结束的时候(就是这条有向路结束了)再将其设置为false)当它在找到一条边v→w时候,w已经在栈中(这里的是表示这个点已经在这条路上出现过了,也就是在onStack中已经标记过了)它就找到可一个有向环,环上的顶点和无向图一样可以通过edgeTo数组获得。这里在检测遇到的点w是否在栈中,不像无向图那样检查是否在marked中标记过,是因为,在有向图中的环必须是一条首尾结点一样的有向路,环上的所有有向边的方向必须一致。
public class DirectedCycle {
private boolean marked[];
private int edgeTo[];
private boolean onStack[];
List<Integer> list;
private boolean flag;
public DirectedCycle(DiGraph G) {
//标记整个图上的遍历过的结点
marked = new boolean[G.V()];
//标记某条有向路上遇到的所有节点
onStack = new boolean[G.V()];
edgeTo = new int[G.V()];
for (int v = 0; v < G.V(); v++) {
dfs(G, v);
}
}
private void dfs(DiGraph G, int s) {
onStack[s] = true;
marked[s] = true;
for (int w : G.adj[s])
if (hasCycle())
return;
else if (!marked[w]) {
dfs(G, w);
edgeTo[w] = s;
} else if (onStack[w]) {
list = new ArrayList<>();
for (int v = s; v != w; v = edgeTo[v])
list.add(v);
list.add(w);
list.add(s);
}
onStack[s]=false;
}
public boolean hasCycle() {
return list.isEmpty();
}
public List<Integer> cycle() {
return list;
}
}
3)拓扑排序:
拓扑排序:给定一幅有向图,给所有的结点排序,排序后使得有向边均从排在前面的结点元素指向排在后面的结点元素(或者说明这个有向图不能进行拓扑排序)。在对一个有向图进行拓扑排序的时候,必须保证它是无环的有向图,因为有环的图不能做到拓扑有序。
其实对于标准的深度优先搜索算法添加一行代码就能实现这个问题。在使用深度优先搜索的时候,正好只会访问每个结点一次,如果将dfs()访问的结点存储在一个数据结构中,然后遍历这个结构就可以访问图中所有结点,遍历的顺序取决于这个数据结构的性质以及是在递归前还是递归后保存
1) 前序:在递归前将顶点放入队列中
2) 后序:在递归调用之后将顶点放入队列中
3) 逆后序:在递归调用之后将顶点压入栈中。
一幅有序无环图的拓扑排序就是所有顶点的逆后序排列。
public class DFSOrder {
private boolean[] marked;
private List<Integer> pre; //前序
private List<Integer> post; //后序
private Deque<Integer> reseverpost; //逆后序
public DFSOrder(DiGraph G){
marked=new boolean[G.V()];
pre=new ArrayList<>(G.V());
post=new ArrayList<>(G.V());
reseverpost=new ArrayDeque<>(G.V());
for(int v=0;v<G.V();v++)
dfs(G,v);
}
public DFSOrder(EdgeWeightDigraph G){
marked=new boolean[G.V()];
pre=new ArrayList<>(G.V());
post=new ArrayList<>(G.V());
reseverpost=new ArrayDeque<>(G.V());
for(int v=0;v<G.V();v++)
dfs(G,v);
}
private void dfs(DiGraph G, int v) {
pre.add(v);
marked[v]=true;
for(int w:G.adj[v])
if(!marked[w])
dfs(G,w);
post.add(v);
reseverpost.addLast(v);
}
public List<Integer> pre(){
return pre;
}
public List<Integer> post(){
return post;
}
public Deque<Integer> reseverpost(){
return reseverpost;
}
}
拓扑排序实现代码:
public class Topological {
private Deque<Integer> oder=new ArrayDeque<>();
public Topological(DiGraph G){
DirectedCycle cycle=new DirectedCycle(G);
if(!cycle.hasCycle())
{
DFSOrder dfsorde=new DFSOrder(G);
oder=dfsorde.reseverpost();
}
}
public boolean isDAG(){
return oder!=null;
}
public Deque<Integer> order(){
return oder;
}
}
使用深度优先搜索进行拓扑排序,其实是使用了两遍深度优先搜索,一遍是查看有向图中是否存在有向环第二遍就是产生顶点的逆后序。由于两次都访问了所有的顶点和边,所以这个算法需要的时间是和V+E成正比的。
深度优先搜索需要先预处理,而它使用的时间和空间与V+E成正比。且在常数时间内处理关于图的连通性。Union-find也可以进行图搜索,但是实际上,union-find其实更加快,因为不需要构造并表示一幅图,而且union-find算法是一种动态算法,但是深度优先算法需要对图进行预处理。我们在完成只需要判断连通性或是需要完成大量的连通性查询和插入操作混合等类似的任务时,更加倾向于使用union-find算法,而深度优先算法则跟适合实现图的抽象数据类型。
补充说明:Union-find算法(解决动态连通性问题):
问题描述:给出一列整数对,每个整数对(p,q)可以被认为是相连的,我们假定相连是一种等价关系(这种相连的属性满足自反性,对称性以及传递性,从图的角度来看,这个整数对可以看作无向边的两个端点)。这种等价关系将输入的数据对划分为多个等价类(从图的角度来看,是将他们划分为多个连通分量)我们需要设计一种数据结构,来保存已经输入的数据对象(即已知的数据对)的所有信息,并用这些信息来判断,新的数据对是否是相连的(也就是新添加进的边的两个端点是否在同一个连通分支内),将这个问题通俗的称为动态连通性问题,这和我们前面所讲的使用DFS来判断图的连通性可以达到一样的目的,但是这个是在动态生成的过程中判断图的连通性,而DFS需要预处理整个图,不能在动态的过程中来判断。
union-find 算法中API:
1)void union(int v,int w) 在p和q之间添加一条连接
2)int find(int v) p所在的分量的标识
3)boolean connected(int v,int w)如果p和q在同一个分量中,那么返回true
4)int count() 返回连通分量的数目;
在具体实现过程中,选择一个以顶点为索引的数组,来记录对应顶点所在的联通分支的标识(将使用连通分量中某个顶点作为该分支的标识),在union中,如果p和q不在一个连通分量中,那么需要更具不同的算法改变其id数组中的值,如果在同一分量中,忽略不计即可。
public class UF {
private int[] id;
private int count;
public UF(int N) {
id = new int[N];
count = N;
for (int i = 0; i < N; i++)
id[i] = i;
}
public int count() {
return count;
}
public boolean connect(int v, int w) {
return quick_find(w) == quick_find(v);
}
}
这里将介绍三种find-union的实现方法(每种方法只是find和union实现不太相同),但是他们都是根据以顶点为索引的id数组来确定在不在同一连通分量中:
1)quick-find方法:
在这种方法中,同一个连通分量中的id值都是相同的。 在find实现的时候,直接返回id中的值即可。在union实现的时候,先判断给定的数据对是否在同一个连通分量中。如果是就直接忽略,否则,合并p和q所在的连通分量(就是将p所在的连通分量的标识id全部替换成q所在的连通分量标识id(反之亦可)),为此,我们需要遍历整个数组;
public int quick_find(int w) {
return id[w];
}
public void union(int v,int w) {
//如果点v,w在同一个连通分量中,那么不需要采取任何措施
if(connect(v,w)) return;
//否则将v所在的连通分量的标记号全部改为w所在的连通分量的标记号码
int label_v=id[v];
int label_w=id[w];
for(int i=0;i<id.length;i++){
if(id[i]==label_v)
count--;
}
}
2)quick-union方法:
由上述可知,我们对于每对输入都需要遍历整个数组,因此quick-find无法处理大型数据,假设使用quick-find方法并且最后只得到一个连通分支,那么至少需要调用N-1次union方法,每次union方法都需要至少N+3次操作,那么整个算法的性能就是平法级别的。而quick-union方法提高了union的效率,同样也是基于相同的数据结构-由顶点索引的id数组,但是该方法中的id数组的意义有所不同,这里的id[]中的元素都是同一个分量中其他顶点的编号,也有可能是自己,我们将这种联系称为链接。在find方法中,我们从给定的顶点开始,由它的链接得到另外一个顶点,再由这个顶点的链接得到新的顶点,如此继续跟随顶点的连接到达新的顶点,直到链接指向自己(这种链接所对应的顶点被称为跟结点),和quick-find不同,quick-union只有两个顶点开始这一过程并且到达相同的跟结点,才能说它们是同一个分支中的。所以在具体的union实现中,我们需要按照上述过程找寻给定的两个顶点的跟结点,如果跟结点相同(说明这两个顶点在同一个分量中),否则,将其中一个根结点中的链接连接到另外一个跟结点上(也就是为其中一个跟结点的链接重新定义为另外一个跟结点)这种实现find-union方式被称为quick-union方法。
public int find(int v){
while(v!=id[v]) v=id[v];
return v;
}
public void quick_union(int v,int w){
int lable_v=find(v);
int lable_w=find(w);
if(lable_v==lable_w) return;
else
id[lable_v]=lable_w;
count--;
}
3)加权的quick-union:
但是在前面讲的quick-union方法中,可能会出现根据不同的输入可能出现最坏的情况,就是树偏向一边,也就是实现仍然需要平方级别的的消耗,在上述实现思想中,再做个轻微的改进就能极大的提高算法的效率,就是每次合并两个分量的时候,不是随意的合并,而是将较小的分量的跟结点的链接改为较大跟结点。这样就可以在以某种程度上达到平衡性,这里需要维护一个size数组,使得跟结点对应索引的元素的值为这个分量的大小(即分量中元素的个数)每次在进行合并需要改变id值得时候,比较两个分量的跟结点的所在的分量的大小,总是将小的分量的链接改为大的分量的跟结点,并且跟新新的合成分量的跟结点的大小。
public void quick_weight_union(int v, int w) {
int lable_v = find(v);
int lable_w = find(w);
if (lable_v == lable_w)
return;
if (size[v] > size[w])
id[lable_w] = lable_v;
else
id[lable_v] = lable_w;
count--;
}
4)使用路径压缩的加权quick-union方法:
在这种方法中,使得每个节点都直接连接在其跟结点上,但是我们又不想像quick-find那样遍历整个数组,因此,在检查顶点的同时将他们直接连接在跟结点上,要想实现路径压缩,只需要为find方法加上一个循环,将在路上遇到的所有节点都连接到跟结点上。
二.最小生成树:
在下面的讨论中做出如下约定:
1) 只考虑连通图;
2) 边的权重可以是任何数;
3) 所有边的权重都各不相同(如果存在权值相同的边,最小生成树不唯一);
切分定理(解决最小生成树的所有算法的基础):
将加权图中的所有顶点分成两个集合(两个非空且不重合的集合),检查横跨两个集合的所有边(这种边被称为横切边:一条连接两个不属于同一集合顶点的边),并识别那条边是否应该属于图的最小生成树。
切分定理:在一幅加权图中,给定任意的划分,它的横切边中权值最小者必然属于最小生成树。(证明:使用反证法,假设e为权值最小的横切边,T为图的最小生成树。如果T中不包含e,那么必然包含一条横切边f,将e边加入最小生成树中,形成了一个环,包含e, f边,那么将f从环中删去,生成一个新的生成树T’显然,新的生成树比原来的最小生成树更小,这与已知矛盾)
从切分定理可知,(在假设前提下,所有边的权值都不同,那么最小生成树是唯一的)对于每一种切分,权值最小的横切边必然属于最小生成树。但是,权值最小的横切边并不是所有横切边中唯一属于图的最小生成树的边,实际中,一次切分产生的横切边可能有多条属于最小生成树。
所有求解最小生成树的算法都是使用的贪心策略(根据切分定理,每次选择一种划分,使得所有的横切边都没有被标记,那么选择权值最小的横切边,直至选择了V-1条边为止。只不过对于不同的算法所使用的切分方法和判断权值最小的横切边的方式有所不同。)
1)Prim算法:
思想:最开始树中只有一个顶点,每次为生长中的树添加一个边,直至添加了V-1条边为止,每次添加的边都是树中的顶点和非树中的顶点所划分的两个集合的横切边中最小的边。
在具体实现中使用一些简单的数据结构来实现树中点的标记(使用boolean类型的顶点索引数组来标记顶点是否在树中),待选择横切边(使用优先队列来处理带选择的横切边,根据横切边的权重),生成树中的边(顶点索引的Edge对象数组来保存最小生成树中的边)。
我们使用一个方法来为树添加顶点,将这个顶点标记为已访问,并且将与它关联的所有未失效的(连接新加入的节点和其他已经在树中的顶点的所有边失效)边加入优先队列中。然后取出优先队列中权值最小的边,并且检查这个边是否有效,如果有效,将这个边的不在树中的点标记为已访问,并将这个边加入最小生成树的边集合中,然后从优先队列中删除这个边。调用新的顶点来更新横切边集合。
prim算法的延时实现代码:
class Edge {
int v;
int w;
double weight;
public int other(int v) {
if (v == this.v)
return w;
else
return v;
}
public int either() {
return v;
}
}
class EdgeWeightGraph {
int V;
public List<Edge>[] adj;
public EdgeWeightGraph(int v) {
for (int i = 0; i < v; i++) {
adj[i] = new ArrayList<>();
}
}
public int V() {
return V;
}
}
public class LazyPrimMST {
private boolean[] marked; //标记最小生成树的顶点
private List<Edge> mst; //标记最小生成树的边
private MinPQ<Edge> pq; //横切边(包含了失效的边)
public LazyPrimMST(EdgeWeightGraph G) {
marked = new boolean[G.V()];
mst = new ArrayList<>(G.V());
pq = new MinPQ<Edge>();
visit(G, 0);
while (!pq.isEmpty()) {
Edge temp = pq.delMin();
int v = temp.either(), w = temp.other(v);
if (marked[v] && marked[w])
continue;
mst.add(temp);
if (!marked[v])
visit(G, v);
else if (!marked[w])
visit(G, w);
}
}
}
private void visit(EdgeWeightGraph G, int i) {
marked[i] = true;
for (Edge temp : G.adj[i])
if (!marked[temp.other(i)]) {
pq.insert(temp);
}
}
}
优化思想:改进Prim的延时实现,可以尝试在优先队列中删除失效的边,这样优先队列就可以只包含真正的横切边。关键的优化思想在于:需要在意的只是连接树顶点和非树顶点中权重最小的边,也就是说,当我们将V添加到生成树顶点集合中去后,对于每个非树顶点w不用保存w到树的每条边,只需要保存权重最小的那个边。也就是说,在优先队列中,只需要保存每个非树顶点的一条边:将它和树中顶点连接起来权重最小的那个边,其他权重较大的边迟早会失效。
即使实现的Prim算法:使用两个顶点索引数组edgeTo[] 和distTo[]来替换延时实现中的marked和mst数据结构;
EdgeTo如果顶点v不在树中,但是至少含有一条边和树相连,那么edgeTo[v] 就是将v和树连接的最小权重的边,而这个distTo则是这条边的权重。
将这类顶点v都保存在一条索引优先的队列中,索引v关联的值是edgeTo的边的权值。
2)Kruskal算法:
思想:按照边的权重顺序来处理它们,依次将当前权值最小的边加入生成树的边中(加入的边不能和已经加入的边构成环)直至加入了V-1条边。在整个过程中,加入的边组成的森林随着新加入的边渐渐合成一棵树。
使用一个优先队列来将所有的边(也就是为所有边按照权值排序),然后再使用union-find数据结构识别新加入的边是否会和已有的树中的边形成环(由于这是在动态处理中识别环是否存在,这里使用union-find(由于深度优先算法需要预处理整个图,在这里不是很适用),最后用一个队列或者别的数据结构来保存最小生成树的所有边。
三.最短路径算法:
在具体实现中使用到的类型结构;
1) 带有权重的有向边:
public class DirectedEdge {
private int v;
private int w;
private double weight;
public DirectedEdge(int v, int w, double weight) {
this.v = v;
this.w = w;
this.weight = weight;
}
public int form() {
return v;
}
public int to() {
return w;
}
double weight() {
return weight;
}
}
2) 加权有向图:
public class EdgeWeightDigraph {
private int V;
private int E;
public List<DirectedEdge>[] adj;
public EdgeWeightDigraph(int V) {
this.V = V;
this.E = 0;
adj = new ArrayList[V];
for (int v = 0; v < V; v++)
adj[v] = new ArrayList<>();
}
public int V() {
return V;
}
public int E() {
return E;
}
public void addEdge(DirectedEdge edge) {
adj[edge.to()].add(edge);
E++;
}
public List<DirectedEdge> adj(int v) {
return adj[v];
}
public List<DirectedEdge> edges(){
List<DirectedEdge> list=new ArrayList<>();
for(int v=0;v<V;v++)
for(DirectedEdge temp:adj[v])
list.add(temp);
return list;
}
}
在具体实践中用到的数据结构:
1) 最短路径树的边:edgeTo[v]:由顶点索引的DirectedEdge的数组,其中edgeTo[v]的值是树中连接v和它的父节点的边
2) 到达起点的距离:distTo[]数组其中distTo[v]代表从点v到达起点的已知的最短路径;
边的松弛:
最短路径的API都基于一个松弛操作,放松边v→w意味着检查从s(起点)到w的最短轮径是否先到达v,再从v→w,如果是,则根据这个情况来跟新数据结构的内容。也就是,那么distTo[v]与边v→w边的权重的和就可能成为新的s到达w的最短路径,并且跟新distTo[v],也就是说distTo[v]+e(vw).weight < distTo[w],否则就说这条边失效了,忽略它。
顶点的松弛:
将一个顶点所连接的所有边进行松弛操作(这里的边松弛和上述过程一样)。
1)Dijkstra算法:
该算法采用了的思想和在求最小生成树时候使用的prim算法类似的思想,首先将distTo[s]设置为0,distTo的其他元素设置为正无穷大,然后将distTo中最小的非树顶点放松并加入到树中,如此这般,直至所有顶点都在树中,或者所有非树顶点的distTo都无穷大。
Dijkstra能解决边权值非负的加权的有向图的最短路径问题:
public class Dijkstra {
private DirectedEdge[] edgeTo;
public double distTo[];
private IndexMinPQ<Double> pq;
private boolean flag = false;
public Dijkstra(EdgeWeightDigraph G, int s) {
edgeTo = new DirectedEdge[G.V()];
distTo = new double[G.V()];
for (int v = 0; v < G.V(); v++)
distTo[v] = Double.POSITIVE_INFINITY;
distTo[s] = 0.0;
pq = new IndexMinPQ<>();
pq.insert(s, 0.0);
while (!pq.isEmpty()) {
int v = pq.delMin();
relax(G, v);
/*
* 求解给定两点的最短路径
* if (v == t) {
flag = true;
break;
}*/
}
}
private void relax(EdgeWeightDigraph G, int v) {
for (DirectedEdge edge : G.adj[v]) {
int w = edge.to();
if (distTo[w] > distTo[v] + edge.weight()) {
distTo[w] = distTo[v] + edge.weight();
edgeTo[w]=edge;
if (pq.contains(w))
pq.change(w, distTo[w]);
else
pq.insert(w, distTo[w]);
}
}
}
public Deque<DirectedEdge> pathTo(int t) {
if (!hasPath(t))
return null;
Deque<DirectedEdge> list=new ArrayDeque<>();
for(DirectedEdge edge=edgeTo[t];edge!=null;edge=edgeTo[edge.from()])
list.push(edge);
return list;
}
private boolean hasPath(int t) {
return distTo[t]<Double.POSITIVE_INFINITY;
}
}
2)无环加权有向图中的最短路径算法:
对比Dijstra算法来说,对于无环加权有向图的最短路径有个好的改进算法,该算法:
a) 能够在线性时间内解决单点最短路径;
b) 能够处理权值为负的边
c) 能够解决相关问题(譬如,最长路径求解)
这些都是在无环有向图的拓扑排序算法的简单扩展,特别的是,将顶点放松和拓扑排序结合起来就能得到这种解决无环加权有向图的最短路径的简单算法。
首先将distTo[s]初始化为0,其他distTo数组元素初始化正无穷大,然后按照拓扑排序来一个个放松顶点。(这种方法能在于V+E成正比的时间内解决无环加权有向图的最短路径问题)
对于无环问题来说,这种拓扑排序和放松相结合的算法,大大简化了问题的判断,而且这种算法和边的权值的正负无关。但是只能适用于无环结构(有环结构不能进行拓扑排序)。
import java.util.ArrayDeque;
import java.util.Deque;
public class Dijkstra {
private DirectedEdge[] edgeTo;
private double distTo[];
public Dijkstra(EdgeWeightDigraph G, int s) {
edgeTo = new DirectedEdge[G.V()];
distTo = new double[G.V()];
for (int v = 0; v < G.V(); v++)
distTo[v] = Double.POSITIVE_INFINITY;
distTo[0] = 0;
Topological top = new Topological(G);
Deque<Integer> dp = top.order();
for (int v = 0; v < G.V(); v++) {
relax(G, dp.poll());
}
}
private void relax(EdgeWeightDigraph G, Integer w) {
for (DirectedEdge edge : G.adj[w])
if (distTo[edge.to()] > distTo[w] + edge.weight()) {
distTo[edge.to()] = distTo[w] + edge.weight();
edgeTo[edge.to()] = edge;
}
}
public double distTo(int v) {
return distTo[v];
}
public boolean hasPath(int v) {
return distTo[v] < Double.POSITIVE_INFINITY;
}
public Deque<DirectedEdge> pathTo(int v) {
if (!hasPath(v))
return null;
else {
Deque<DirectedEdge> list = new ArrayDeque<>();
for (DirectedEdge s = edgeTo[v]; s != null; s = edgeTo[s.form()])
list.push(s);
return list;
}
}
}
使用上面的算法还能快速的解决无环加权有向图的单点最长路径问题。解决无环加权有向图的最长路径问题需要的时间和E+V成正比。(证明,复制原来的无环加权有向图的一个副本,并把副本中的所有边的权值取相反数,这样副本中的最短路径就是原图中的最长路径。其实在、实现的一个更简单的方法就是,将distTo的初始值变为负无穷,然后改变松弛的条件方向)
对于这个算法还可以运用在优先级限制下的并行任务调度(这就是个无环加权有向图):
问题描述:给定一组需要完成的任务及其需要完成的时间,一级乙组关于任务完成的优先级限制顺序。在满足优先级限制的条件下,应该如何在若干个处理器上安排任务并在最短的时间内完成所有的任务。存在一个线性时间内的算法“关键路径的方法被证明和无环加权有向图的最长路径问题是等价的。
解决并行任务调度的关键路径方法的步骤如下:创建一副无环加权的有向图,其中包含一个起点s和一个终点t且每个任务都对应着两个顶点(一个起始顶点,一个任务结束顶点,两个顶点间边的长度为任务完成所需要的时间)对于每条优先级限制v→w添加一条从v到w的权重为0的边,还需要为每个任务添加一个从起始点s指向该任务的起始点的权重为0的边,以及一条从该任务的结束点到达终点t的权重为0的边。这样,每个任务的预计开始时间就是从起始点s到达该任务起始点的最长距离。
3)一般加权有向图中的最短路径算法(BellmanFord算法):
思想:在任意含有V个顶点的加权有向图中给定起始点s,从s无法到达任何负权重环,以下算法就能解决其中的单点最短路径问题,将distTo[s]初始化为0,其他元素的distTo初始化为正无穷大,然后以任意顺序放松有向图中的所有边,重复V轮。但是根据经验,我们会很容易的得出,任意一轮中许多边的放松都不会成功,只有上一轮中distTo值发生变化的顶点连接的边才能够改变其他distTo中的元素值,我们可以使用FIFO队列来纪录发生变化的顶点。
实现的时候使用下面两种数据结构:
1)一条用来保存即将被放松的顶点的队列;
2)一个由顶点索引的boolean数组,用来指示顶点是否在队列中,防止重复的往队列中添加顶点。
为了完整的实现V轮候算法能够终止,实现这个目的一种方法就是显示的纪录轮数。
负权重值的检测
图类算法总结