1.应用场景:
上一篇博客学习了图论基础https://blog.csdn.net/j_oop/article/details/108018641介绍了无权图的算法实现以及路径计算获取,在实际生产场景中带权图往往更接近实际需求。最小生成树解决的是在n个顶点中找到n-1条边使得所有顶点全部连通并且n-1条边的权重之和最小(针对无向图)。
2.带权图的算法实现
上一篇博客介绍了在稠密图中,我们使用一个n*n(n为顶点数)的邻接矩阵(二维bool数组)mp_graph存储顶点之间的连接关系,mp_graph[i][j]为true表示图中含有由第i个顶点指向第j个顶点的边,反之则反;对于稀疏图我们使用邻接表mp_graph(二维int数组)来表示,mp_graph[i]数组中含有j元素表示图中含有由第i个顶点指向第j个顶点的边,否则表示没有由i指向j的边。带权边是一个相对复杂的数据结构不能用一个基本数据类型来表达,因此先将边Edge封装成一个类,在原有的稀疏图和稠密图的算法实现上邻接表和邻接矩阵改为Edge的二维数组,就实现了带权图。
Edge:
//带权图中边的表达
template<typename Weight>
class Edge
{
public:
Edge(int v,int w,Weight weight)
{
this->v = v;
this->w = w;
this->weight = weight;
}
Edge(void){}
~Edge(void){}
//返回第一个顶点
int V()
{
return v;
}
//返回第二个顶点
int W()
{
return w;
}
Weight wt()
{
return weight;
}
//返回边上的另一顶点
int other(int a)
{
assert(a==v || a==w);
return a==v ? w : v;
}
friend ostream& operator<<(ostream &os,const Edge &e)
{
os<<e.v<<"-"<<e.w<<":"<<e.weight;
return os;
}
//边的大小比较,实际上是权重的比较
bool operator>(const Edge& e)
{
return weight>e.weight;
}
bool operator>=(const Edge& e)
{
return weight>=e.weight;
}
bool operator<(const Edge& e)
{
return weight<e.weight;
}
bool operator<=(const Edge& e)
{
return weight<=e.weight;
}
bool operator==(const Edge& e)
{
return weight==e.weight;
}
private:
//顶点索引
int v,w;
//权值
Weight weight;
};
稠密图(WeightDenseGraph):
class WeightDenseGraph
{
private:
//图的总顶点个数
int mp_vertexCount;
//图的边计数
int mp_edgeCount;
//是否为有向图
bool mp_isDirected;
//图的具体数据(顶点之间的连接关系),一个mp_vertexCount*mp_vertexCount矩阵
vector<vector<Edge<Weight>*>> mp_graph;
public:
//vertexCount为顶点数,isDirected是否为有向图
WeightDenseGraph(int vertexCount,bool isDirected)
{
mp_vertexCount = vertexCount;
mp_edgeCount = 0;
mp_isDirected = isDirected;
mp_graph = vector<vector<Edge<Weight>*>>(vertexCount,vector<Edge<Weight>*>(vertexCount,NULL)); //初始化顶点之间全不连接(即没有边)
}
~WeightDenseGraph(void)
{
for(int i=0;i<mp_graph.size();i++)
{
for(int j=0;j<mp_graph[i].size();j++)
{
if(mp_graph[i][j]!=NULL)
delete mp_graph[i][j];
}
}
}
int E(){return mp_edgeCount;} //返回边条数
int V(){return mp_vertexCount;} //返顶点的个数
//向图中添加边
void addEdge(int v,int w,Weight weight)
{
assert(v>=0 && v<=mp_vertexCount-1);
assert(w>=0 && w<=mp_vertexCount-1);
if(hasEdge(v,w)) //添加边已存在,删除原存在边进行替换
{
delete mp_graph[v][w];
if(!mp_isDirected)
delete mp_graph[w][v];
mp_edgeCount--; //维护边条数
}
mp_graph[v][w] = new Edge<Weight>(v,w,weight);
if(!mp_isDirected) //无向图,添加反向连接
{
mp_graph[w][v] = new Edge<Weight>(w,v,weight);
}
mp_edgeCount++;
}
//判断两顶点是否连接,时间复杂度O(1)
bool hasEdge(int v,int w)
{
assert(v>=0 && v<=mp_vertexCount-1);
assert(w>=0 && w<=mp_vertexCount-1);
return mp_graph[v][w] != NULL;
}
//打印输出图中所有边
void show()
{
for(int i=0;i<mp_vertexCount;i++)
{
cout<<i<<": ";
for(int j=0;j<mp_vertexCount;j++)
{
if(mp_graph[i][j])
cout<<mp_graph[i][j]->wt()<<"\t";
else
cout<<"NULL\t";
}
cout<<endl;
}
}
class adjIterator
{
private:
//目标顶点所在图
WeightDenseGraph& G;
//当前遍历位置
int index;
//遍历的目标顶点
int v;
public:
adjIterator(WeightDenseGraph& graph,int v):G(graph)
{
assert(v>=0 && v<graph.V());
this->v = v;
this->index = -1; //邻接矩阵中,顶点所在行的第一个连接顶点不一定从0开始
}
~adjIterator()
{}
//邻接矩阵中
Edge<Weight>* begin()
{
index = -1; //begin可能会多次调用
return next();
}
bool end()
{
return index >= G.V();
}
Edge<Weight>* next()
{
for(index++;index<G.V();index++) //遍历邻接矩阵中v顶点所在行,不为NULL的下一条边
{
if(G.mp_graph[v][index]!= NULL)
return G.mp_graph[v][index];
}
return NULL;
}
};
};
稀疏图(WeightSparseGraph):
//稀疏图,用邻接表实现(允许存在平行边,允许存在自环边)
template<typename Weight>
class WeightSparseGraph
{
private:
int mp_vertexCount; //顶点个数
int mp_edgeCount; //边条数
bool isDirected; //是否为有向图
vector<vector<Edge<Weight>*>> mp_graph;
public:
WeightSparseGraph(int n,bool isDirected)
{
mp_vertexCount = n;
mp_edgeCount = 0;
this->isDirected = isDirected;
mp_graph = vector<vector<Edge<Weight>*>>(n,vector<Edge<Weight>*>());
}
~WeightSparseGraph(void)
{
for(int i=0;i<mp_graph.size();i++)
{
for(int j=0;j<mp_graph[i].size();j++)
delete mp_graph[i][j];
}
}
int V(){return mp_vertexCount;} //返回顶点个数
int E(){return mp_edgeCount;} //返回边条数
//添加一条边
void addEdge(int v,int w,Weight weight)
{
mp_graph[v].push_back(new Edge<Weight>(v,w,weight)); //不判断是否已经存在边,过于损耗程序性能,因此该实现允许存在平行边
if(v!=w && !isDirected) //对于自环边,不需要再次插入反向边
mp_graph[w].push_back(new Edge<Weight>(w,v,weight));
mp_edgeCount++;
}
// 验证图中是否有从v到w的边,最差情况下时间复杂度O(V)
bool hasEdge( int v , int w ){
//对于无向边,由于在插入时保证了反向边的加入,因此在判断是否存在边时无须再次区分有(无)向图
assert( v >= 0 && v < n );
assert( w >= 0 && w < n );
for( int i = 0 ; i < mp_graph[v].size() ; i ++ )
if( mp_graph[v][i] != NULL)
if(mp_graph[v][i]->other(i) == v)
return true;
return false;
}
//打印输出边
void show()
{
for(int i=0;i<mp_vertexCount;i++)
{
cout<<"vertex"<<i<<": ";
for(int j=0;j<mp_graph[i].size();j++)
{
cout<<"( to:"<<mp_graph[i][j]->W()<<",wt:"<<mp_graph[i][j]->wt()<<")\t";
}
cout<<endl;
}
}
//稀疏图迭代器,传入一个图和一个顶点
class adjIterator
{
private:
//目标顶点所在图
WeightSparseGraph &G;
//指示当前遍历的位置
int index;
//要遍历的顶点
int v;
public:
adjIterator(WeightSparseGraph& graph,int v):G(graph)
{
this->index = 0;
this->v = v;
}
~adjIterator()
{
}
//返回目标顶点的邻接表中第一个顶点
Edge<Weight>* begin()
{
this->index = 0; //begin可能会多次调用
if(G.mp_graph[v].size() >0)
return G.mp_graph[v][index];
return -1;
}
//是否已经遍历到目标顶点邻接表尾部
bool end()
{
return this->index >= G.mp_graph[v].size();
}
//返回目标顶点邻接表中的下一个顶点
Edge<Weight>* next()
{
index++;
if(index < G.mp_graph[v].size())
{
return G.mp_graph[v][index];
}
return NULL;
}
};
};
3.切分定理
切分:将图任意分成两部分;
横切边:如果一条边的两个顶点分别属于切分不同的两边,那么这条边称为横切边;
切分定理:给定任意切分,横切边中权值最小的边必定属于最小生成树。
4.最小生成树(LazyPrime算法,OElogE)
根据切分定理,我们选定一个起始点将图切分成两部分:起始点和其他点。这样就可以找到最小生成树中与该起始点相连的边,再将图切分为该边和其他边两部分,根据切分定理又可以找到最小生成树的又一条边,重复该过程即可找到最小生成树。在算法实现中我们使用最小堆(可参考https://blog.csdn.net/j_oop/article/details/107307066)来存储所有的横切边,使用vector<Edge>来存储已确定的最小生成树中的边,使用bool数组来标记当前图的切分情况(为true表示在同一切分即最小生成树中,false为另一切分)。难点在于:随着顶点加入最小生成树所在切分,横切边队列中原先满足横切边的边可能不再是横切边,解决方法代码中已经给出注释。
LazyPrime:
template<typename Graph,typename Weight>
class LazyPrim
{
public:
LazyPrim(Graph graph):g(graph),pq(MinHeap<Edge<Weight>>(graph.E()))
{
marked = new bool[g.V()];
for(int i=0;i<g.V();i++)
{
marked[i] = false;
}
mst.clear();
visit(0);
while(!pq.isEmpty()) //时间复杂度E(图有多少条边)
{
Edge<Weight> edge = pq.extractMin(); //取出权值最小的横切边,时间复杂度logE
if(marked[edge.V()]==marked[edge.W()]) //在向最小生成树插入边的过程中会改变队列中一些边的的属性,即原先是横切边的边可能不再是横切边
continue;
mst.push_back(edge);
mstWeight += edge.wt();
// 访问和这条边连接的还没有被访问过的节点
if( !marked[edge.V()] )
visit( edge.V() );
else
visit( edge.W() );
}
}
~LazyPrim(void)
{
delete[] marked;
}
//返回最小生成树的所有边
vector<Edge<Weight>> mstEdges(){
return mst;
};
// 返回最小生成树的权值
Weight result(){
return mstWeight;
};
private:
//访问节点,将其邻接节点放入优先队列中,
void visit(int v)
{
assert(!marked[v]);
marked[v] = true; //将当前节点标记切分
Graph::adjIterator adj(g,v);
for(Edge<Weight>* e = adj.begin();!adj.end();e=adj.next()) //遍历邻接节点
if(!marked[e->other(v)]) //判断是否是横切边
pq.insert(*e); //加入横切边队列中,时间复杂度logE
}
private:
//标记切分,为true表示属于切分中的一部分,false为另一部分
bool* marked;
//横切边队列
MinHeap<Edge<Weight>> pq;
//图
Graph& g;
//最小生成树
vector<Edge<Weight>> mst;
//最小生成树的权值
Weight mstWeight;
};
对LazyPrime进行算法效率分析:最外层循环while(!pq.isEmpty())的时间复杂度是E级别的,循环内pq.extractMin()时间复杂度是logE级别,visit方法中pq.insert(*e)也是logE级别的,因此LazyPrime算法的时间复杂度是O(ElogE)。
5.LazyPrime优化,Prime算法O(ElogV)
LazyPrime算法中通过将横切边存入最小堆中,每次取出权值最小的边来获取最小生成树的一条边,即根据边来找顶点。
而事实上最小生成树就是找到E-1条边来连接E个顶点使得权值之和最小,每次循环确定连接一个顶点的路径。也就是说我们可以每次遍历顶点(即visit)维护未确定顶点(marked为false的顶点)的候选横切边,再根据切分定理依次确定每个顶点的路径。
先上代码:
/************************************************************************/
/* 对lazyPrime进行改进 */
/************************************************************************/
template<typename Graph,typename Weight>
class PrimeMST
{
private:
//图
Graph &G;
//最小生成树
vector<Edge<Weight>> mst;
//最小生成树的权值
Weight mstWeight;
//最小索引堆,存储横切边权值
IndexMinHeap<Weight> ipq;
//候选横切边列表
vector<Edge<Weight>*> edgeTo;
//标记切分
bool* marked;
void visit(int v)
{
Graph::adjIterator adj(G,v);
for(Edge<Weight>* e = adj.begin();!adj.end();e = adj.next())
{
int w = e->other(v);
if(!marked[w])
{
if(!edgeTo[w]) //当前顶点没有侯选边.直接赋值
{
edgeTo[w] = e;
ipq.insert(w,e->wt());
}
else if(e->wt()< edgeTo[w]) //已经存在候选边,如果当前边权值小于侯选边,进行替换
{
edgeTo[w] = e;
ipq.change(w,e->wt());
}
}
}
}
public:
PrimeMST(Graph &graph):G(graph),ipq(IndexMinHeap<double>(graph.V()))
{
assert(graph.E() >= 1);
marked = new bool[graph.V()];
for(int i=0;i<graph.V();i++)
{
marked[i] = false;
edgeTo.push_back(NULL);
}
mst.clear();
visit(0);
while(!ipq.isEmpty()) //O(E)
{
int v = ipq.extractMinIndex(); //O(logE)
if(edgeTo[v])
{
mst.push_back(edgeTo[v]);
visit(v);
mstWeight+=edgeTo[v]->wt();
}
}
}
~PrimeMST(void);
};
可以看到算法的时间复杂度变成了O(ElogE),除此之外,由于每次遍历顶点维护的是顶点对应的侯选边列表且需要进行频繁替换操作,最小堆无法实现替换指定索引的值,因此使用了最小索引堆,可以参考https://blog.csdn.net/j_oop/article/details/107307066
6.Kruskal算法
Kruskal算法的思想是,依次取出图中权值最小的边加入到最小生成树,如果当前最小生成树中构成了环,则该边不在最小生成树中,未构成环则该边存在在最小生成树中。代码实现中使用并查集来判断最小生成树中是否存在环,并查集可参考https://blog.csdn.net/j_oop/article/details/107558581
template<typename Graph,typename Weight>
class KruskalMST
{
private:
//最小生成树
vector<Edge<Weight>> mst;
//最小生成树权值
Weight mstWeight;
public:
KruskalMST(Graph &g)
{
MinHeap<Edge<Weight>> pq (g.E());
for(int i=0;i<g.V();i++)
{
Graph::adjIteratorz adj(g,i);
for(Edge<Weight>*e=adj.begin();!adj.end();e=adj.next())
if(e->V()<e->W()) //无向图所有的边都存了两遍,此处保证所有的边只加入最小堆一次
pq.insert(*e);
}
//并查集
UF5::UnionFind uf(g.V());
while(!pq.isEmpty() && mst.size()<g.V()-1) //当最小生成树已确定,提前终止循环
{
Edge<Weight> e = pq.extractMin(); //取出权值最小的边
if(uf.isConnected(e.V(),e.W())) //形成了环,跳过
{
continue;
}
//不形成环,加入最小生成树并合并顶点
mst.push_back(e);
uf.unionElements(e.V(),e.W());
}
mstWeight = mst[0].wt();
for( int i = 1 ; i < mst.size() ; i ++ )
mstWeight += mst[i].wt();
}
~KruskalMST(void);
};
Kruskal算法时间复杂度分析:Kruscal首先对所有的边进行了排序,时间复杂度ElogE,取最小边时间复杂度ElogE,并查集合并顶点时间复杂度ElogV,所以综合起来Kruskal算法效率要低于Prime算法。