图详解第三篇:最小生成树(Kruskal算法+Prim算法)

文章目录

  • 最小生成树
    • 1. 最小生成树概念
    • 2. Kruskal算法
      • 算法思想
      • 代码实现
      • 测试
      • 3. Prim算法
        • 算法思想
        • 代码实现
        • 测试
        • 4. 源码

          最小生成树

          1. 最小生成树概念

          在了解最小生成树之前,我们先来回顾一下生成树的概念,这是我们之前文章提到过的:

          无向图中,一个连通图的最小连通子图称作该图的生成树(不能带环)。

          有n个顶点的连通图的生成树有n个顶点和n-1条边。

          比如:

          那我们今天要学的最小生成树和生成树是什么样的一个关系呢?

          🆗,这里的最小其实是指的边的权值之和最小,当然是要在保证它是生成树的前提下权值之和最小。

          所以,对于一个连通图来说,在它的所有的生成树里面,边的权值之和最小的生成树就是该连通图的最小生成树,当然最小生成树也可以有多个,因为边的权值是可以相等的。

          连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。

          若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三条:

          1. 只能使用图中权值最小的边来构造最小生成树

          2. 只能使用恰好n-1条边来连接图中的n个顶点

          3. 选用的n-1条边不能构成回路

          构造最小生成树的方法:

          Kruskal算法和Prim算法。

          这两个算法都采用了逐步求解的贪心策略。

          贪心算法:

          是指在问题求解时,总是做出当前看起来最好的选择。

          也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。

          贪心算法不是对所有的问题都能得到整体最优解。

          那下面我们就来学习一下这两种求解最小生成树的算法:

          2. Kruskal算法

          首先我们来学习第一个——克鲁斯卡尔算法( Kruskal)

          算法思想

          它的思想是这样的:

          任给一个有n个顶点的连通图N={V,E},

          首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量(集合),其次不断从E中取出权值最小的一条边(若有多条权值相等任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。

          核心:每次迭代时,选出一条具有最小权值的边,且边的两端点不在同一连通分量(集合)上,则加入生成树。

          那这里呢我们截取了《算法导论》上的一张图,大家可以看一下:

          其实就是每次从图中所有的边里面选出权值最小且不会构成环的边,选够n-1条就完成了,这n-1条边构成的生成树就是该图对应的最小生成树。

          那大家想一下我们可以怎么做,从而能保证每次选出合适的边?

          其实呢也好办:

          我们可以搞一个优先级队列(priority_queue),并控制它是小堆,先遍历邻接矩阵把所有的边都放到priority_queue里面,这样我们后续就很方便每次取出最小的边。

          但是选出来的边,我们不能盲目的使用,而要去判断,连接上这条边之后是否会构成环(借助并查集判断,将所有相连边的顶点放到一个集合里面,后续在添加边,判断如果这条边对应的两个顶点在一个集合,就会构成环)

          比如:

          gf、fc、ci这三条边是相连的,那这4个顶点就在一个集合里面,现在添加ig的话,i和g在一个集合,所以会构成环,不能添加。

          如果会构成环的话,就放弃这条边,继续选剩下边里面最小的边如果合适就添加这条边,并且将对应的顶点用并查集合并到一个集合里面,便于后面判断连接新的边是否会构成环。

          这样最终我们就可以选出权值之和最小的生成树即最小生成树。

          代码实现

          那我们来写一下代码

          首先:

          我们typedef一个self(这样简洁一些,不typedef那就把上面的类名完整写下来),因为我们要构建最小生成树,这个最小生成树是和我们当前这个图同样结构的一个子图嘛,所以它们的类型是一样的。

          然后这里我们最终就返回得到的最小生成树的边权值之和。

          那然后我们就来写一下内部的逻辑

          那首先呢,我们应该对这里的最小生成树minTree初始化一下:

          因为之前我们自己构造一个图的时候,是用的带参的构造

          我们直接传了顶点数组和个数让它对成员进行了初始化。

          但是我们现在构建最小生成树的时候可能不会再去传顶点数组些东西了,按理说就不需要了,因为最小生成树和原图的结构是一致的。

          所以我们一般就直接这样

          直接默认构造一个图,然后把它构建成最小生成树。

          当然我们这里可以生成一个默认构造,之前没写

          所以函数里面我们来对minTree初始化一下

          最小生成树的顶点集合、顶点和下标的映射和原图肯定都是一样的,我们直接赋给它。

          区别就在于最小生成树的边跟原图不一样嘛,所以邻接矩阵(存储边)不能跟原图一样,我们先开好空间,全部初始成MAX_W。

          那然后,按照我们上面的分析,先搞一个优先级队列,把所有的边存进去,方便后面选最小的边:

          那这里要存储边的话,有一个问题:

          我们之前的边是怎么表示的,可以认为是存在矩阵里面的,矩阵里面一个位置的值不是MAX_W,就代表对应的两个顶点是相连的,那它们两个就构成一条边,存的值就是权值。

          那现在我们要把它存到优先级队列priority_queue里面,好像没法弄啊?

          而且priority_queue默认大堆,我们要搞成小堆的话,就要传仿函数Greater,所以还要重载边的>运算符。

          所以,我们这里为了好搞,我们封装一个边的类

          那然后我们就来定义一个优先级队列(搞成小堆)并把原图所有的边存进去

          那接着,我们就依次从priority_queue中选取n-1条最小边连接最小生成树minTree即可,当要借助并查集判断如果构成环的边不能添加:

          并查集呢我们之前实现过了,这里我就直接用了

          然后呢,大家有没有注意到一个细节:

          之前我们写的添加边的函数不是AddEdge嘛,这里调用的怎么是_AddEdge呢?

          🆗,其实是因为在这里不能直接用AddEdge。

          因为这里添加边是用顶点映射的下标添加的

          而之前我们写的AddEdge是指定顶点添加的

          内部转换成下标再去添加的

          所以呢

          可以把之前AddEdge里面用下标添加边的部分单独封装成一个子函数——_AddEdge

          这样我们Kruskal算法这里直接调用这个子函数就可以了

          那我们就写好了,来测试一下:

          测试

          我已经构建好了一个图,就是按照上面给大家看的那个算法导论里面的那张图构建的,那现在我们用它来测试一下:

          运行程序

          我们可以跟图上面对照一下

          大家可以看一下,正好跟图上的是一样的。这里打印边其实可以不加的,我们这里只是为了方便观察。

          当然大家看ah这条边其实和bc的权值其实是一样的,所以这条边其实选它们两个都可以,我们上面说了最小生成树不一定唯一。

          3. Prim算法

          接着我们来学求最小生成树的第二个算法——普里姆(Prim)算法

          算法思想

          首先我们还是可以来看一下算法导论中给的一个图:

          大家可以自己先看一下

          通俗一点说,Prim算法呢其实是这样做的:

          首先呢,还是选一个顶点作为起点,选哪个都可以;然后呢,它在选边的时候把图里面的顶点分成了两个集合,一个集合是已经被选到的结点组成的集合,另一个集合是剩下的结点组成的集合。

          每次选边的时候是从两个集合中的顶点直接相连的边中选取权值最小的那一条。

          举个栗子,比如就拿上图中选前两条边的过程为例:

          首先起点选的是a这个结点,那开始时,两个集合,比如起名为X和Y,已经选到的结点集合X里面就只有一个结点a,剩下的结点就在另一个集合Y里面

          然后从X中的结点与Y中的结点直接相连的边中选取权值最小的边。

          那这里第一次选的时候X中只有a一个结点,与剩余结点里面直接相连的边只有两条,ab和ah,那ab的权值小,所以选的是ab。

          那这样b被选中,就加入到X集合中,Y集合中删除b

          第二次从a、b与剩余结点直接相连的边中选取权值最小边。

          那大家看a、b两个结点与剩余结点直接相连的边有ah、bh、bc,只有这3条,那其中ah和bc的权值是一样的,都是8,所以两条都可以,当然图上选的是bc这一条。

          那后续的操作也是一样的,把c添加到X集合中,Y集合中删除c,继续选择…

          后面我就不全部列举出来了

          那Prim算法这样选边有什么好处呢?

          🆗,其实我们观察一下能够发现,它这样去选边,选出来的边是不会构成环的。

          因为它每次选边的时候是从两个集合里面的顶点直接相连的边里面去选的。

          它天然就避环了,大家回忆一下我们上面Kruskal算法避环的时候不就是判断它们在不在一个集合嘛,不在的话就可以连接添加这条边。

          代码实现

          那下面我们来看看代码如何实现?

          首先呢如果用Prim算法的话可以指定起点:

          那前面的操作呢其实还是一样的,把minTree初始化一下:

          接着下面呢我们就要想一想如何把所有的顶点分为两个集合,然后去选边了:

          首先呢集合是很好搞的,假如我们要搞一个X集合存已经选到的点,Y集合存剩下没选的顶点。

          那我们就可以用两个vector对象X,Y,开顶点个数个空间,X全部初始化为false,Y全部初始化为true,那个顶点在X里,就把X中对应的位置设置为true,Y里面对应位设置成false。

          然后我们这里呢还是借助一个优先级队列priority_queue去选边,怎么做呢?

          比如还是上面那个图:

          起点是a,那a放到X集合,剩下的在Y集合,然后先把a和Y中结点直接相连的边,放到优先级队列中,然后选出最小的是ab(那ab即每次被选到的边就要从优先级队列中删掉,因为后续肯定不能再选它了)

          然后b添加到X集合,b从Y中删掉,再把b与Y集合中直接相连的边(bh、bc)添加到优先级队列中,选出最小的边

          当然这一次其实选bc或ah都可以,然后后续也是一样…

          每选中一条边之后,首先两个集合里的元素要变(X增加一个,Y减去一个),其次把X中新增的那个结点和Y中剩余结点直接相连的边添加到优先级队列中,选出最小的边。

          但是,如果按照上面的方法去选的话,我们依然要判断选择一条边的时候会不会构成环:

          其实单按照这个Prim算法的思想去选的话,是不需要判环的,我们上面也解释了。

          但是,我们现在用优先级队列去选的话是需要判断的,比如:

          大家看这种情况,现在黑色的结点时已经选到的,在X集合中,剩下两个白色结点d、e在Y中,那按照Prim算法我们现在能选的边——X集合和Y集合中顶点直接相连的边,只有cd、fe和fd三条。

          但是我们现在从优先级队列中选边的时候,是会选到gi这条边的(后面可以带大家验证一下),因为gi还在优先级队列里面啊。

          因为前面我们选到i结点的时候

          i和Y中其它结点直接相连的边(ih、gi)就被放到了优先级队列里面,但是我们后续没有选到它,那也就不会pop掉它,所以它一直还放在优先级队列里面,那我们后面就有可能会选到。

          那要判断的话其实也好判断,如果被选的这条边的两个顶点都在X集合里,那就构成环,就不能选。

          那分析清楚了,我们来继续写代码:

          我们可以先不添加判环的逻辑,然后写好我们看一下,是不是会出现我们上面说的那个环:

          首先,搞两个集合XY,分别存储被选到的顶点和剩下的顶点,那第一次就是起点放X中,剩下的都在Y中

          然后先把起点直接相连的边添加到优先级队列minq中

          然后就开始选边了(先不加判环逻辑):

          代码就不解释了,注释很清晰

          然后

          返回的逻辑和上面Kruskal算法一样

          那我们来测试一下:

          34,比上面Kruskal最终得权值和还小呢!

          不过我们先不和Kruskal对比,我们得先看看Prim选的对不对,因为刚才我们没有判环,我们看是不是会出现我们上面说的gi那条边选上之后构成环

          我们可以看到,前面选的都是没有问题的(只不过第二条边选的是ah而不是图上的bc,不过没关系权值相等,那个都可以),但是gi就选错了,就构成了

          所以我们要加上判环的逻辑:

          如果被选的边的两个顶点都在X集合里,那就构成环,就不能选

          测试

          我们再来看一下:

          这次就没问题了,只是第二条边选的不一样跟图上,当然都可以因为这两条边权值相等。

          而且这次它的权值和是37,就和上面Kruskal算法是一样的

          对于Prim我们还可以这样玩一下,我们来试试循环用图中所有的顶点做起点看看结果会不会不一样:

          打印边的代码我就先注释掉

          🆗,都是37,不过它们对应的最小生成树的边可能会不同。

          4. 源码

          struct Edge
          {size_t _srci;
          	size_t _dsti;
          	W _w;
          	Edge(size_t srci, size_t dsti, const W& w)
          		:_srci(srci)
          		, _dsti(dsti)
          		, _w(w)
          	{}
          	bool operator>(const Edge& e)const
          	{return _w > e._w;
          	}
          };
          W Kruskal(Self& minTree)
          {size_t n = _matrix.size();
          	//初始化minTree
          	minTree._vertexs = _vertexs;
          	minTree._indexMap = _indexMap;
          	minTree._matrix.resize(n);
          	for (size_t i = 0; i < n; ++i)
          	{minTree._matrix[i].resize(n, MAX_W);
          	}
          	//将原图的边添加到priority_queue中
          	priority_queue, greater> minque;
          	for (size_t i = 0; i < n; i++)
          	{for (size_t j = 0; j < n; j++)
          		{if (i < j && _matrix[i][j] != MAX_W)
          			{minque.push(Edge(i, j, _matrix[i][j]));
          			}
          		}
          	}
          	//依次从priority_queue中选最小n-1条边连接minTree即可(判断构成环的不能选)
          	int count = 0;
          	UnionFindSet ufs(n);
          	W totalW = 0;
          	while (!minque.empty())
          	{Edge minEg = minque.top();
          		minque.pop();
          		if (!ufs.IsInSet(minEg._srci, minEg._dsti))
          		{cout << _vertexs[minEg._srci] << "->" << _vertexs[minEg._dsti] << ":" << minEg._w << endl;
          			minTree._AddEdge(minEg._srci, minEg._dsti, minEg._w);
          			ufs.Union(minEg._srci, minEg._dsti);
          			++count;
          			totalW += minEg._w;
          			if (count == n - 1)
          				break;
          		}
          		else
          		{cout << "构成环(不选):";
          			cout << _vertexs[minEg._srci] << "->" << _vertexs[minEg._dsti] << ":" << minEg._w << endl;
          		}
          	}
          	if (count == n - 1)
          		return totalW;
          	else
          		return W();
          }
          W Prim(Self& minTree, const V& src)
          {size_t n = _matrix.size();
          	minTree._vertexs = _vertexs;
          	minTree._indexMap = _indexMap;
          	minTree._matrix.resize(n);
          	for (size_t i = 0; i < n; i++)
          	{minTree._matrix[i].resize(n, MAX_W);
          	}
          	size_t srci = GetVertexIndex(src);
          	vector X(n, false);
          	vector Y(n, true);
          	X[srci] = true;
          	Y[srci] = false;
          	priority_queue, greater> minq;
          	//先把起点直接相连的边添加到minq中
          	for (size_t i = 0; i < n; i++)
          	{if (_matrix[srci][i] != MAX_W)
          		{minq.push(Edge(srci, i, _matrix[srci][i]));
          		}
          	}
          	//开始选边
          	size_t count = 0;
          	W totalW = 0;
          	while (!minq.empty())
          	{//选出最小的边
          		Edge minEg = minq.top();
          		minq.pop();
          		//如果选出来的最小边的终止顶点也在X集合中,就会构成环,不能选
          		if (X[minEg._dsti])
          		{/*cout << "构成环(不选):";
          			cout << _vertexs[minEg._srci] << "->" << _vertexs[minEg._dsti] << ":" << minEg._w << endl;*/
          		}
          		else
          		{//添加选出的边
          			minTree._AddEdge(minEg._srci, minEg._dsti, minEg._w);
          			//cout << _vertexs[minEg._srci] << "->" << _vertexs[minEg._dsti] << ":" << minEg._w << endl;
          			++count;
          			totalW += minEg._w;
          			if (count == n - 1)
          				break;
          			//把新选出的顶点添加到X,从Y删除
          			X[minEg._dsti] = true;
          			Y[minEg._dsti] = false;
          			//再选出新增顶点与Y中顶点直接相连的边放到minq中
          			for (size_t i = 0; i < n; i++)
          			{//该边的终止顶点不能在X中
          				if (_matrix[minEg._dsti][i] != MAX_W && X[i] == false)
          				{minq.push(Edge(minEg._dsti, i, _matrix[minEg._dsti][i]));
          				}
          			}
          		}
          	}
          	if (count == n - 1)
          		return totalW;
          	else
          		return W();
          }