最近在复习数据结构和算法的的内容,栈和队列的思想是比较深刻,借于许多高级语言都有相应的框架实现了栈和队列链表等,所以对于这一类,我们只需要了解其思想,在真正操作时,也会显得比较简单。但是还有一类数据结构是稍显复杂的,在高级语言的程序里面并没有相应的框架,比如树和图。树一般可用节点结构体来封装一个节点,但是图,图的话就不容易表示了,因为图是无序的,每个节点与其他节点都有任意的连通性。但是基于使用图的操作目的而言,一般有:搜索(遍历)、最小生成树、寻找节点之间的最小路径等。其目的都是为了存储点对之间的连通性,以及通路的代价,为此,我们可以根据我们的使用目的对其进行抽象为:邻接表、邻接矩阵、十字链表。
连通图的最小生成树
最小生成树其实在计算机网络里面也有应用:在有线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\n",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语言版) 清华大学出版社
计算机算法设计与分析