图是一种非常常见的抽象数据结构,它的应用场合可以说非常的多。图的表示一般有两种,一种方法是使用一个二维数组,称为邻接矩阵来表示,但是这种方式对于稀疏的图来说非常的浪费空间,另一种常用的便是邻接表了,它将每一个节点都保存在数组里,所有和该节点相连接的节点则链接在该节点后面。图分为有向图和无向图两种。下面就在图的具体应用中来谈谈。
1、拓扑排序(有向图)
拓扑排序是对有向无圈图的一种排序,它使得如果存在一条路径从i到j,那么i必定在j的前面。拓扑排序一个最常见的例子便是课程的选修安排,一门课程必须在另一门或另几门课程修读后才能修读。
注意的是在有圈的图中拓扑排序是无论如何都不会成立的。一个简单而明了的算法来完成拓扑排序是:我们找到任意一个没有入度的边,然后将其删除,紧接着将所有和它相连的边也删除掉,这样子与它相连的节点的入度减1,然后重复上述操作。这里存在的一个优化是,新产生的入度为0的节点必定是由上一个节点导致的,也就是说这种有序性其实我们可以用一个队列来保持这种顺序性,当一个节点的度为0时我们入队,然后不停的出队,直到最后队列为空。这样可以将数次的搜索入度为0的操作去掉,优化算法。同时如果最终队列为空时候我们进行的次数不等于节点数目,说明有圈的存在,这可以运用于很多要求判断是否有圈的应用中。
一个图我们用邻接表来保存,其结构可以是这样子的:
typedef class node *gnode;
typedef gnode *graph;//指向指针的指针(即由gnode组成的数组)
typedef int vertex;
struct node{
vertex value;
gnode next;
};
除此之外我们还需呀额外的支持,一个是我们需要将每个节点的入度保存起来,可以用一个数组来进行。然后为了记录整个拓扑排序过程,用一个数组来记录每个节点的拓扑号:
int topnum[num_vertex];//用来保存拓扑序号,注意的时数组下标和编号对应
int indegree[num_vertex];//用来保存每个顶点的入度,当然最简便的方法时将入度放到临接表的头单元中
当然简便一点的操作是,我们直接将其放入头节点中。一个拓扑排序操作的代码是这样的:
//我们假设已经将图读入到了一个邻接表中
void top_sort(graph G)
{
queue q;//建立一个队列
int counter=0;//计数当前top号
gnode v,w;
q=create_queue(num_vertex);
make_empty(q);
for each gnode v
if(indegree==0)
enqueue(v,q);
while(!is_empty(q))
{
v=dequeue(q);
topnum[v]=++counter;
for each w adjacent to v
if(--indegree[w]==0)
enqueue(w,q);
if(counter>num_vertex)
break;
}
if(counter!=num_vertex)
printf("there is a circle!");
dispose_queue(q);
}
//上面的算法还可以用来检验一个给出的有向图是否有圈
值得注意的是这个算法还可以用来检验图是否有圈,如果counter不等于节点数目那么便是有圈了。
2、最短路径(有向图)
最短路径可以分为带权的和不带权的最短路径两种。无权最短路径的寻找可以用广度优先搜索来实现,它的思路很简单,既然都无权,那么我越早找到你当然也就路径最短了。算法的代码和拓扑排序非常像,不同的是我们出入队列的条件不同。为了实现这个效果,而不使得原来的数据结构变得紊乱,我们添加新的数据结构:
typedef struct table_entry{//table_entry是一个更复杂一点的邻接表的头结构,它捎带了额外的信息,放在一个数组中,而每一个和它邻接的节点
//则通过header链接起来
gnode header;
int known;
int distance;
gnode path;
}table_entry;
typedef table_entry *table;
可以看到我们的改动是,再头节点为每一个节点添加了一些额外的信息,用来支持我们的算法。最短路径中要用到的额外信息是:known它标明当前节点是否已经被确定已经访问过,对于标记为known的节点我们不再考虑对他进行操作,distance标明当前节点离起始节点的距离,正是通过比较distance,我们才确定一个节点是否应该被选择为known,path记录了整个最短路径中的过程,它的值是前一个指向它的节点。一个无权图的最短路径算法,即广度优先搜索看起来是这样的:
void unweighted(table t,gnode start)
{
queue q;
gnode v,w;
q=create_queue(q);
makeempty(q);
enqueue(start,q);
while(!empty(q))
{
v=dequeue(q);
t[v].known=true;
for each w adjacent to v
if(t[w].distance==infinity)
{
t[w].distance=t[v].distance+1;
t[w].previous=v;
enqueue(w);
}
}
dispose_queue(q);
}
我们还是需要一个队列,由于有队列的支持以及距离的改变,known已经不再需要了,不过我们还是将其添加进来,用以明确。
然后是要复杂许多的带权的最短路径的算法,它是一个贪心算法,即dijkstra算法,它像无权的算法一样按阶段进行,每一个阶段它都会选择一个顶点v,v具有最小的距离值,将其申明为已知的(known=true),然后对于每一个和v相连的顶点w,我们更新它们的距离值,然后迭代的又从所有顶点中选中一个最小值顶点进行同样的操作,直到所有顶点都被访问到为止。
一个dijkstra算法看起来是这样的:
void dijkstra(table t)
{
vertex v,w;
while(1)
{
v=find_smallest_unknown_vertex(t);
if(v==not_vertex)
break;
t[v].known=true;
for each w adjacent to v
if(!t[w].known)
if(t[v].distance+value<t[w].distance)
{
decrease(t[w].distance to t[v].distance+value);
t[w].path=v;
}
}
}//value 为两点间的消耗,我们可以将它放在链表中的链接节点内
它会一直寻找最小距离节点,直到所有的节点都被申明为已知为止。当然这个算法还需要额外的支持,比如说对于table的初始化操作:
//首先时将数据读入
void init_table(gnode start,graph g,table t)
{
int i;
read_graph(g,t);
for(i=0;i<num_vertex;i++)
{
t[i].known=false;
t[i].distance=infinity;
t[i].path=not_vertex;
}
t[start].distance=0;
}
在将数据从邻接表读入到table里面后,还需要对一些数据进行初始化,比如距离申明为无限大,路径尚未存在,known声明为未知等。当然前面的深度优先搜索也需要这种支持(known不需要)。在进行过dijkstra算法后,所有节点到开始节点的路径便是已知的了,我们可以设计一个算法来递归的打印出路径:
void print_path(vertex start,table t)
{
if(t[start].path!=not_vertex)
{
print_path(t[start].path);
printf("to");
}
printf("%d",start);
}
//递归打印出这条路径
3、网络流问题(无向图)
一个最大流问题要解决的是从一个节点到另一个节点所能允许的最大的流量。一种可能的算法是,我们每选择了一条边的某个值为备选路径,那么则往其相反方向画一条等值的路径,直到最后没有从s通向t的节点为止。
4、最小生成树(无向图)
一个无向图的最小生成树指的是由改图那些连接图的所有顶点的边构成的树,它们的总价值最小。最小生成树要存在当且仅当图是连通的。一颗最小生成树中,添加一条边,便会形成一个圈,删除一条边则会使图变为非连通的。
最小生成树的生成算法有两种,它们都是很出名的贪婪算法的例子。一个是prim算法,一个是kruskal算法。不过它们的出发点并不一样。prim算法的思想是使得一个最小生成树一步步的成长,每一步都要把一个节点当做根,往上加边,这样也就把相关联的顶点增加到了树上。任意时刻,我们都可以看到一个已经添加到树上的顶点集,而其余节点尚未添加到顶点集中,每一步我们的操作便是:选定一条边(u,v),使得u在顶点集中,v不在顶点集中,并且使得(u,v)是所有可以的选择中最小的。这样一步步操作,直到所有的顶点都位于顶点集中便结束操作。
另一种kruskal算法的思想则不太相同,它贪婪的策略是按照最小的权选择边,并且当所选的边不会形成圈时,将它选定。其算法的思想是再处理一个森林,开始的时候是存在|V|棵单节点树,而添加一条边的作用则是将两棵树合并为一颗树。算法终止的时候,就只有一棵树了。那么算法的主要目的是要决定(u,v)这条边是应该添加还是舍弃。这个时候不相交集中的find/union操作便可以直接拿来用了。如果u,v属于一个集合则舍弃,否则便添加,并将u,v两个顶点所在的树合并。
一个kruskal算法看起来是这样的:
void kruskal(graph g)
{
int edge_accepted=0;//用来记录当前收录了多少条边,一共需要收录顶点数减一条边
disjset s;
heap h;//用堆来保存边可以每次delete_min删除堆顶元素即可
gnode u,v;
settype uset,vset;//集合,用来判断一条边的两节点是否在同一集合中
edge e;
init(s);
read_graph_to_heap(g,h);
buildheap(h);
while(edge_accepted<num_vertex-1)
{
e=deletemin(h);//一条边包含(u,v)两个顶点
uset=find(e.u,s);
vset=find(e.v,s);
if(uset!=vset)
{
edge_accepted++;
set_union(s,uset,vset);//将这两个集合合并
}
}
}
同时注意的是因为我们每次都删除一条最小边,因此可以用一个最小堆来存储这些边,每次只需要删除堆顶元素即可获得最小边。算法一直运行,直到我们接受了的边的条数等于总节点数减一。