题目连接: 还是畅通工程
题目:
某省调查乡村交通状况,得到的统计表中列出了任意两村庄间的距离。省政府“畅通工程”的目标是使全省任何两个村庄间都可以实现公路交通(但不一定有直接的公路相连,只要能间接通过公路可达即可),并要求铺设的公路总长度为最小。请计算最小的公路总长度。
Input
测试输入包含若干测试用例。每个测试用例的第1行给出村庄数目N ( < 100 );随后的N(N-1)/2行对应村庄间的距离,每行给出一对正整数,分别是两个村庄的编号,以及此两村庄间的距离。为简单起见,村庄从1到N编号。
当N为0时,输入结束,该用例不被处理。
Output
对每个测试用例,在1行里输出最小的公路总长度。
Sample Input
3
1 2 1
1 3 2
2 3 4
4
1 2 1
1 3 4
1 4 1
2 3 3
2 4 2
3 4 5
0
Sample Output
3
5
解题思路:
本题我会提供prim, prim+优先队列, kruskal, kruskal+优先队列 共四种代码, 两种解题思路:
1.普里姆算法:
普里姆算法的重点是图中的点, 由于我们需要让图中所有的点关联起来, 所以这个算法的核心是弄两个集合, 一个集合A表示已经搜寻完的点集, 另一个集合B表示还未搜寻的点集. 而我们的目的就是让图中所有的点都在集合A中即可.
题解出发点:
因为最终每一个点都应该在集合A中, 所以我们可取图中任一点作为我们的起始点, 称之为key点, 然后我们去寻找看看key点能到某个点(称为D点)的距离最短, 这个D点就是下一个应该被纳入集合A中的点. 假设现在D已经被我们纳入了集合A中, 当我们再找寻下一个D’点的时候, 我们应该考虑key点和D点能到的所有点中距离最近的.
这样通过贪心的思想, 我们每次都取一个min{A集合某点到B集合某点的距离}, 我们就可以完成我们的目的.
代码角度分析:
我认为困难点就在于, 把题解出发点中的A集合与B集合代码化. 其实无脑一点, 你可以开一个二维数组, dis[x][y]表示从x到y的距离. 但是这样太傻了, 太傻了, 太傻了.
所以我们有如下优化: 我们不妨就只开一个数组dis来存key点到所有点的距离, 当每次添加新的点D到集合A中时, 我们维护这个dis数组, 看看我们取 min{从key点到某点(称为X)的距离, 从D点到X点的距离}, 这样我们只需要一个一维数组即可.
剩下的分析我们看AC代码部分
普里姆代码:
#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int MAXN = 105, INF = 0x3f3f3f3f;
int mp[MAXN][MAXN]; //存图
int dis[MAXN]; //表示key点到另外点的距离, 后续代码默认key点取第一个点
bool vis[MAXN]; //表示点是否在集合A中
int n;
void initialize() {
memset(dis, 0x3f, sizeof(dis));
memset(mp, 0x3f, sizeof(mp)); //这两步memset表示默认不可达
memset(vis, 0, sizeof(vis)); vis[1] = 1;
for (int i = 1; i <= n * (n - 1) / 2; ++i) {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
mp[a][b] = mp[b][a] = c;
}
for (int i = 1; i <= n; ++i) dis[i] = mp[1][i];
//注: dis[1] = 0, for从2开始跑都是可改可不改的,
//因为我们的点1已经标记为集合A的点了.
}
void prim() {
int res = 0;
for (int i = 1; i <= n - 1; ++i) { //需要让其余n-1个点进入集合A
int index = min_element(dis + 1, dis + 1 + n) - dis;
res += dis[index]; vis[index] = 1; //更新结果, 让index进入集合A
for (int i = 1; i <= n; ++i) {
if (!vis[i]) dis[i] = min(dis[i], mp[index][i]); //维护dis
}
dis[index] = INF; //这是为了min_element()函数(可手写)
}
cout << res << endl;
}
int main(void)
{
while (scanf("%d", &n), n) {
initialize();
prim();
}
return 0;
}
引入优先队列:
引入优先队列的目的也就是为了不再去寻找dis中的最小值, 整体思路几乎是一样的, 直接看代码吧.
普里姆代码(引入优先队列):
#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int MAXN = 105, INF = 0x3f3f3f3f;
int mp[MAXN][MAXN]; bool vis[MAXN];
int n;
struct node {
int dis, id;
bool operator < (const node& t) const {
return dis > t.dis; //优先队列由于是按照优先级排序, 因此从小到大排序要用 >
}
};
priority_queue<node> q;
void initialize() {
while (!q.empty()) q.pop();
memset(vis, 0, sizeof(vis));
memset(mp, 0x3f, sizeof(mp));
for (int i = 1; i <= n * (n - 1) / 2; ++i) {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
mp[a][b] = mp[b][a] = c;
}
}
void prim() {
int temp = 1; //表示当前要加入集合A的点
int res = 0;
for (int i = 0; i < n - 1; ++i) { //n-1次循环, 没有标记最后进入集合A的点, 但是不影响结果
vis[temp] = 1; //temp点加入集合
for (int j = 1; j <= n; ++j) {
if (!vis[j]) q.push({ mp[temp][j], j });
}
while (!q.empty() && vis[q.top().id]) q.pop(); //找到我们要添加的点
node op = q.top(); q.pop();
res += op.dis; temp = op.id;
}
cout << res << endl;
}
int main(void)
{
while (scanf("%d", &n), n) {
initialize();
prim();
}
return 0;
}
2.克鲁斯卡尔算法:
克鲁斯卡尔算法和普里姆算法的最大区别就是, 普里姆算法的重点是图中的点, 而克鲁斯卡尔的重点是图中的边.
这个算法比上个算法的入手难度稍微高一点点, 涉及到了并查集的基础知识.
题解出发点:
该算法希望把图中所有的边都记录下来, 从小到大进行排序, 每次取出边中的最小值, 看看通过该边相连的两个节点是否属于同一集合, 如果不是的话则将它们连通.
从而我们可以发现, 这个算法的本质是对边进行排除, 而普里姆算法则是对点进行排除, 在普里姆算法中: 我们可以确定当我们把n个点都放在集合A中, 那么我们一定完成了最小生成树. 但是在克鲁斯卡尔算法中, 我们只能知道当我们把所有的边都排除了之后, 我们才完成了最小生成树.
那么我们为什么要用到并查集呢? 像prim算法那样记录点不可以吗?
答案当然是否定的. 假如我们第一条最短边连接了点M和N, 然后把M, N两点放入集合A(代码中即用vis数组去标记), 然后第二条最短边连接了P和Q, 我们此时如果再把P和Q放入集合A中, 那么在我们的程序算法中就会认为M, N, P, Q四点都已经连通了, 这显然是错误的, 我们明明只有M和N, P和Q是连通的, 但是他们之间是不连通的.他们彼此形成了一个小集合, 是最终我们要求的集合A的两个不相关的子集.
为了避免这种情况的发生, 因此我们就需要引入并查集的概念. 但是本文不对并查集进行讲解.
代码角度分析:
我认为, kruskal算法在你理解并查集和prim算法的基础上来讲, 好像代码角度也没什么难度.
但是在此提示一点: 请注意克鲁斯卡尔算法是存的所有边的数据, 因此请注意数组的大小!!!,
对于本题来说, 如果你忽略了这点, 像普里姆算法那样开了105, 那真是各种T和WA的辛酸史.
克鲁斯卡尔代码:
#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
int n, m; const int MAXN = 10000; //要开大点!
int pre[MAXN]; //存放上一级节点
struct Edge {
int a, b, dis;
bool operator < (const Edge& t) const {
return dis < t.dis;
}
}edge[MAXN];
void union_found() { //并查集的建立
for (int i = 0; i <= n; ++i) pre[i] = i;
}
int union_find(int x) { //找到点x的根节点
if (pre[x] == x) return x;
return union_find(pre[x]);
}
void initialize() {
union_found();
m = n * (n - 1) / 2;
for (int i = 0; i < m; ++i) {
scanf("%d %d %d", &edge[i].a, &edge[i].b, &edge[i].dis);
}
}
void kruskal() {
int res = 0;
sort(edge, edge + m);
for (int i = 0; i < m; ++i) {
Edge op = edge[i];
const int a = union_find(op.a), b = union_find(op.b);
if (a == b) continue;
res += op.dis;
pre[b] = a; //注意, 在集合关联的时候, 要将两个集合的根节点进行关联
}
cout << res << endl;
}
int main(void)
{
while (scanf("%d", &n), n) {
initialize();
kruskal();
}
return 0;
}
引入优先队列:
这个算法引入优先队列, 其实啥都没改变.
克鲁斯卡尔代码(引入优先队列):
#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
int n, m; const int MAXN = 10000;
int pre[MAXN];
struct Edge {
int a, b, dis;
bool operator < (const Edge& t) const {
return dis > t.dis; //注意大于号排序
}
};
priority_queue<Edge> q;
void union_found() {
for (int i = 0; i <= n; ++i) pre[i] = i;
}
int union_find(int x) {
if (pre[x] == x) return x;
return union_find(pre[x]);
}
void initialize() {
union_found();
m = n * (n - 1) / 2;
for (int i = 0; i < m; ++i) {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
q.push({ a ,b, c });
}
}
void kruskal() {
int res = 0;
while (!q.empty()) {
const Edge op = q.top(); q.pop();
int a = union_find(op.a);
int b = union_find(op.b);
if (a == b) continue;
res += op.dis;
pre[b] = a;
}
cout << res << endl;
}
int main(void)
{
while (scanf("%d", &n), n) {
initialize();
kruskal();
}
return 0;
}
算法对比分析:
普里姆(prim)算法: 一般认为时间复杂度为 O(n2), n为节点数.
克鲁斯卡尔(kruskal)算法: 一般认为时间复杂度为 O(n*logn), n为边数.
所以不难看出, 当图中边少的情况, 我们采用kruskal算法, 而边多的情况, 我们可以采用prim算法
两者的共同缺点就是, 都不能处理成环问题.