图论之最小生成树(C++) – 找最近的朋友拉拉手
最小生成树(MST, Minimum Spanning Tree)顾名思义就是一棵树,这颗树要满足两个要求,一是这是一棵树,既然是一颗树就不能有环,也就是如果在找到的最小生成树中去掉任意一条边,都会将这棵树分成两颗,数学说法是两个连通分量,二是所有图中的顶点都在树中;想象一个房间里塞满了人,全是抠脚大汉,油腻多汗,出去的条件是所有人都手拉手(一个人可以和多个人拉手),但是只有相互认识的大汉才能拉手,如果你是房主,当然你也在房间里,而且在抠脚大汉全都出去前你要坚守阵地(没办法,房主嘛,必须要有但当),为了能尽快脱离浓郁的氛围,最好的做法就是让相互认识又离得最近的大汉拉起手,这里将大汉看作顶点,将相互认识看作边,最小生成树的各边就是各个抠脚大叔能拉起手的最短路径;
最小生成树的基本玩法有两个,一个是Kruskal算法,另一个是Prim算法;
- Kruskal算法:整体思路就是最小生成树中的边一定是尽可能小的边,把所有不重复顶点的最短边放到一起就是最小生成树,这其中用了并查集(需对并查集有一定了解,可以查看并查集相关),其局限性是只能用于无向图;
- Prim算法:Prim生成最小生成树的方式用的是切分的玩法,也就是从图中随便切出一块,这时被切掉的边中最小的边必然在在最小生成树中,不断地将最小边连接的顶点加入到最小生成树的顶点集合中,直到所有顶点都加入,最小生成树就生成了;Prim玩法需要用索引堆进行优化,这里为了方便理解,没有用索引堆(对索引堆优化有兴趣可以先看下索引堆的实现),而是用最小堆(最大堆传送门,最小堆同理实现,只是改下大于小于号)的方式实现,这时也就被称为Lazy Prim;
- 带权图的边类:边可能有权值,设置一个边类,即可设定是否有向,也可以存储权值;
- 边的遍历是在图中有用到自定义的图的边的遍历器,其实可以在图中加一个方法返回边来简化,通过设置一个遍历器,优化了空间复杂度,相关图和遍历器的设置在图遍历传送门;
带权图的边
// 有权图的边类
template<typename Weight>
class Edge{
private:
int vA, vB; // 边的两个顶点
Weight w; // 边的权值
public:
// 构造函数
Edge(int vA, int vB, Weight w){
this->vA = vA;
this->vB = vB;
this->w = w;
}
// 空的构造函数, 所有的成员变量都取默认值
Edge(){}
~Edge(){}
int getVA(){ return vA;} // 返回第一个顶点
int getVB(){ return vB;} // 返回第二个顶点
Weight getWeight(){ return w;} // 返回权值
// 给定一个顶点, 返回另一个顶点
int linkTo(int x){
assert( x == vA || x == vB );
return x == vA ? vB : vA;
}
// 输出边的信息
friend ostream& operator<<(ostream &os, const Edge &e){
os << e.vA << "-" << e.vB << ": " << e.w;
return os;
}
// 比较符号重载
// 边的大小比较, 是对边的权值的大小比较
bool operator<(Edge<Weight>& e){
return w < e.getWeight();
}
bool operator<=(Edge<Weight>& e){
return w <= e.getWeight();
}
bool operator>(Edge<Weight>& e){
return w > e.getWeight();
}
bool operator>=(Edge<Weight>& e){
return w >= e.getWeight();
}
bool operator==(Edge<Weight>& e){
return w == e.getWeight();
}
};
Kruskal算法 – 最短的,你出来
用一个数组记录顶点是否已在最小生成树中,当然起始的最小生成树为空,将所有边都放入最小堆中,不断让最短边出来,然后查看边所连的顶点是否已在最小生成树中,如果已经在了丢掉,如果不在则该边为最小生成树中的边,注意这时记录顶点是否已在最小生成树中的数组要将该边连的顶点设为true~~
// Kruskal算法
template<typename Graph, typename Weight>
class KruskalMST {
private:
vector<Edge<Weight>> mst;
Weight mstWeight;
public:
KruskalMST(Graph &g) {
MinHeap<Edge<Weight>> minHeap(g.getENum());
UnionFind uf(g.getVNum());
mst.clear();
// 边的指针存入最小堆
for(int i=0; i<g.getVNum(); i++) {
typename Graph::EdgeIterator adj(g,i);
for(Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next()) {
// 因为无向图,所以直接丢掉重复的边
if(e->getVA() < e->getVB())
minHeap.insert(*e);
}
}
// 循环进行最小生成树的边的获取,如果最小堆中没有边了或者最小生成树已有所有顶点则退出循环
while(!minHeap.isEmpty() && mst.size() < g.getVNum() - 1) {
Edge<Weight> e = minHeap.popTheTop();
// 判断并查集中两个顶点是否相连,如果相连说明当前边的两个顶点已在最小生成树中,丢掉该边
if(uf.isConnected(e.getVA(),e.getVB())) {
continue;
}
// 将两个顶点所在集合合并
uf.unionSetByHeight(e.getVA(),e.getVB());
// 将边添加到最小生成树中
mst.push_back(e);
}
// 计算最小生成树总权值
mstWeight = mst[0].getWeight();
for(int i=1; i<mst.size(); i++) {
mstWeight += mst[i].getWeight();
}
}
~KruskalMST() {
}
// 返回最小生成树的所有边
vector<Edge<Weight>> getMstEdges(){
return mst;
}
// 返回最小生成树的权值
Weight getMstWeight(){
return mstWeight;
}
};
Lazy Prim算法 – 那么长,切了
最小生成树的边必定是图中的边,而且最小生成树又必然包含图中所有的顶点,这时当图中有n个顶点,不妨取其中k个顶点(1 <= k <= n-1),这时图就分成了两部分,因为最小生成树的特性,这两部分必定是相连的,且只需要有一条边连起来就ok了,那如果这时有好多条边能将这两部分连起来,选择的肯定是最短的那条了,那些长的ㄟ(▔▽▔)ㄏ,就切了吧;根据这一思路,实际操作时,让k从1开始一直增加,不断找到最短的,找完后也就形成了最小生成树了;需要注意的是,这里默认图是一个连通图,否则最小生成树不存在~~
// Lazy Prim算法
template<typename Graph, typename Weight>
class LazyPrimMST {
private:
// 图
Graph &g;
// 标记顶点是否已访问
bool* visited;
// 最小生成树
vector<Edge<Weight>> mst;
// 最小堆,找到切出来最短的边
MinHeap<Edge<Weight>> minEdge;
// 最小生成树的权值
Weight mstWeight;
// 访问一个节点,将相应的边放入最小堆
void findMinEdge(int v) {
assert(!visited[v]);
visited[v] = true;
// 将与节点v相连接的所有未访问的边放入最小堆中
typename Graph::EdgeIterator adj(g,v);
// 这里是遍历一个点的边,结合构造函数中的while循环就是遍历所有的边也就是一共的时间复杂度是O(E)
for(Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next())
// 如果连接的顶点未加入最小生成树,这个边才加入最小堆中
if(!visited[e->linkTo(v)])
// 时间复杂度O(log(E))
minEdge.insert(*e);
}
public:
// 构造函数,图是引用必须列表初始化,顺便把最小堆也初始化了,因为用到了引用图的边数作为容量
LazyPrimMST(Graph &graph):g(graph), minEdge(MinHeap<Edge<Weight>>(graph.getENum())) {
// 初始化
visited = new bool[g.getVNum()];
for(int i=0; i<g.getVNum(); i++) {
visited[i] = false;
}
mst.clear();
// 随便选一个顶点开始,选0最保险
findMinEdge(0);
// 最小堆中没有边时,说明有连接的顶点都已经遍历到了
while(!minEdge.isEmpty()) {
Edge<Weight> e = minEdge.popTheTop();
// 获取边连接到的顶点
int vB = e.getVB();
if(visited[vB])
continue;
mst.push_back(e);
findMinEdge(vB);
}
// 计算最小生成树的权值
mstWeight = mst[0].getWeight();
for(int i=1;i < mst.size(); i++)
mstWeight += mst[i].getWeight();
}
// 析构函数
~LazyPrimMST() {
delete[] visited;
}
// 返回最小生成树的所有边
vector<Edge<Weight>> getMstEdges(){
return mst;
};
// 返回最小生成树的权值
Weight getMstWeight(){
return mstWeight;
};
};
例行总结:
最小生成树不管用那种方法,整体思路还是比较好理解的,Kruskal的思路很直观,就是从最短的开始,慢慢地把最小生车速拼接起来,将并查集中需要的复杂度看成是常数后,Kruskal算法的整体复杂度是O(Elog(E)),O(E)是循环查看所有的边,O(log(E))是最小堆获取最短边;Lazy Prim的关键点就是切切切,切了找最短边,Lazy Prim的时间复杂度也是O(Elog(E)),整个大循环是获取各个点的每天边,也就是O(E),而最小堆中的边是随着切出来的k个顶点的数量增多而增多的,所以一开始的时候边较少,但整体的时间复杂度还是O(log(E)),效率比Kruskal稍高些,但达不到量级的区别,然而Prim算法用索引堆替换了最小堆后,堆中的边数不会超过V条,所以整体时间复杂度就是O(Elog(V)),对于稠密图的优化效果明显~~