最近在复习数据结构和算法的的内容,栈和队列的思想是比较深刻,借于许多高级语言都有相应的框架实现了栈和队列链表等,所以对于这一类,我们只需要了解其思想,在真正操作时,也会显得比较简单。但是还有一类数据结构是稍显复杂的,在高级语言的程序里面并没有相应的框架,比如树和图。树一般可用节点结构体来封装一个节点,但是图,图的话就不容易表示了,因为图是无序的,每个节点与其他节点都有任意的连通性。但是基于使用图的操作目的而言,一般有:搜索(遍历)、最小生成树、寻找节点之间的最小路径等。其目的都是为了存储点对之间的连通性,以及通路的代价,为此,我们可以根据我们的使用目的对其进行抽象为:邻接表、邻接矩阵、十字链表。
连通图的最小生成树
最小生成树其实在计算机网络里面也有应用:在有线Lan中,为避免交换机之间的连线形成环路,而最终会导致“兜圈子”,从而引起“广播风暴”的现象,Lan中交换机的配置就采用了最小生成树的算法,来避免形成环路。下面介绍两种连通图的最小生成树算法,普里姆算法(Prim)和克鲁斯卡算法(Kruskal),他们在时空消耗上面,各有优劣。但是这里也顺便说,Prim和Kruskal算法都是具是贪心算法的类比,都是从局部最优最后到全局最优的。
(Prim)普里姆算法
其思想是:
1.有两个集合V,S . S代表已经被识别的最小生成树路径上的节点集合,V代表所有节点的集合,V-S 就是剩余未被识别的节点的集合。
2.程序开始时,指定v0 加入S中,使得{v0} = S .
3.在V-S 集合中寻找到下一个节点vi,使得vi 到 S的距离最短。(vi到S的距离是指,vi到S集合中任意一点的距离;当两点直接相连时为连通,否则距离为无穷)。将vi 加入到集合S中。
4.不断运行步骤三,直到S集合包含了所有节点。
由上就是普里姆算法,其思想非常简单,每次都是去取寻找离已识别集合最短的路径,这样局部最优导致全局最优。该算法的时间复杂度为O(n2).
下面给出完整的C++代码实现:
#include <iostream> #include<vector> #include<algorithm> #include<set> #include<string.h> #define N 6 #define MAX_INT 999999 using namespace std; // 边的结构体 typedef struct{ int x; int y; int cost; } Tpath; //连通图的邻接矩阵 int g [N][N] = {{-1,6,1,5,-1,-1},{6,-1,5,-1,3,-1},{1,5,-1,5,6,4},{5,-1,5,-1,-1,2},{-1,3,6,-1,-1,6},{-1,-1,4,2,6,-1}}; void gprim(); //main int main(int argc, char** argv) { gprim(); return 0; } //运行prim算法 void gprim(){ vector<Tpath> p; //记录边 vector<int>u; //集合S u.push_back(0); //将V0加入到S中 int node1= 0,node2 = 0,cost = 0; int i = 0; vector<int>::iterator it; for(u.size(); u.size() < N ;){ // get the lowcost path node1 = -1; node2 = -1; cost = MAX_INT ; for(it = u.begin() ;it != u.end() ; it++){ // 从V-S集合里面寻找到离S集合最lowcost的节点和对应的边。将其记录下来为 为cost,node1,node2 int k = (*it); for(i = 0; i < N ;i++){ if(i == (*it))continue; if(g[k][i] >= 0 && (find(u.begin(),u.end(),i) == u.end()) && g[k][i] < cost){ node1 = k; node2 = i; cost = g[k][i]; } } } // 将该节点加入到S中 并记录下路径path Tpath path; path.cost = cost; path.x = node1; path.y = node2; p.push_back(path); u.push_back(node2); } //输出 vector<Tpath>::iterator itO; for(itO = p.begin() ; itO != p.end() ;itO++){ printf("(%d ,%d) cost: %d ",itO->x+1,itO->y+1,itO->cost); } }
(Kruskal)克鲁斯卡算法
其思想是:
1.引入节点的连通分量的概念:即一个节点与其他哪些节点相连通。
2.程序开始时,每个节点的连通分量就是自己。有集合E,SE,S。E为图中边的集合,SE为图中已经被识别的边的集合。SE开始为{},S为已识别点的集合。
3.从E-SE中选择一条边(vi,vj),其边的两个顶点时是vi,vj:该边的距离是所有E-S中距离最短的。同时,vi的连通分量中不包含vj,vj的连通分量中不包含vi。将(vi,vj)加入到SE中,将vi,vj
加入到S中,同时将vi的连通分量加入vj中,将vj的连通分量加入到vi中。
4.持续运行步骤3,直到S集合包含了所有节点。
由上就是克鲁斯卡算法。分析其算法可知,其时间复杂度度为n(logn) , n 为连通图中边的个数。为什么是O(n(logn))呢?其实很简单,克鲁斯卡的算法中每次都是找的E-SE中最短的边,这里可以使用排序算法对所有的边进行排序(O(nlogn)),然后再执行算法步骤2-4时,就可以依次取出来(O(n))。而这里最大的时间消耗是排序,所以是O(nlogn)。
Kruskal的个人实现:
#include <iostream> #include<vector> #include<algorithm> #include<set> #include<string.h> #define N 6 #define MAX_INT 999999 using namespace std; // 边的结构体 typedef struct{ int x; int y; int cost; } Tpath; //连通图的邻接矩阵 int g [N][N] = {{-1,6,1,5,-1,-1},{6,-1,5,-1,3,-1},{1,5,-1,5,6,4},{5,-1,5,-1,-1,2},{-1,3,6,-1,-1,6},{-1,-1,4,2,6,-1}}; //increase sort bool cmp(const Tpath &p1, const Tpath &p2){ return p1.cost < p2.cost; } void gkruskal(); //main int main(int argc, char** argv) { gkruskal(); return 0; } void gkruskal(){ vector<Tpath> t; int i = 0 ,j = 0; //将所有的边生成一个一个的结构体节点 for(i ; i < N ; i++){ for(j = i+1;j <N ;j++){ if(g[i][j] < 0) continue; Tpath p ; p.x = i; p.y = j; p.cost = g[i][j]; t.push_back(p); } } //按边的距离升序排序 sort(t.begin(),t.end() ,cmp); vector<Tpath>::iterator it; //为每个节点Vi设置连通分量 vector< set<int> > sets; for(i = 0; i < N ;i++){ set<int> v; v.insert(i); sets.push_back(v); } vector<Tpath> p; i = 0; //执行算法,扫描升序边集合 for(;p.size() < N -1 ; ){ set<int> x = sets[t[i].x]; set<int> y = sets[t[i].y]; set<int>::iterator it; //如果该边的两个顶点Vi ,Vj 各自的连通分量不包含对方,就将改变加入到路径集合SE中 if(x.find(t[i].y) == x.end()){ p.push_back(t[i]); set<int>::iterator xi ; //同时将Vj的连通分量加入到Vi的连通分量重 for(xi = sets[t[i].x].begin() ; xi != sets[t[i].x].end(); xi++){ if((*xi) == t[i].x)continue; sets[(*xi)].insert(y.begin(),y.end()); } //同时将Vi的连通分量加入到Vj的连通分量重 for(xi = sets[t[i].y].begin() ; xi != sets[t[i].y].end(); xi++){ if((*xi) == t[i].y)continue; sets[(*xi)].insert(x.begin(),x.end()); } sets[t[i].x].insert(y.begin(),y.end()); sets[t[i].y].insert(x.begin(),x.end()); } ++i; //扫描下一条边 } //输出最小生成树的 边对 for(it = p.begin() ; it != p.end();it++){ cout<<"("<< it->x +1 <<","<<it->y + 1<<")"<<"cost :"<<it->cost<<endl; } }
上面就是两个比较简单,但是比较经典的连通图最小生成树的算法。Prim算法时间复杂度略高,但是空间消耗较少;而Kruskal的算法呢,时间复杂度低,但需要为每个节点设置连通分量的存储空间,因此空间复杂度略高。总之看了这些算法之后,总是对计算机的算法设计有股莫名的倾佩和向往啊!。。。
参考书本:
数据结构(c语言版) 清华大学出版社
计算机算法设计与分析