第八十八天 --- 力扣1584. 连接所有点的最小费用
题目一
思路
将问题抽象化,有一个点集,一个边集,现在需求是从边集中拿出边,将点集连上,两点之间只能有一条边(连成简单图),求权值之和最小,这就是经典的生成树问题,要求权值最小,简称最小生成树。这里介绍三种写法:Prim、堆优化Prim、Kruskal算法,分别有着各自的适用范围。
Kruskal算法
1、它是一种贪心的思想。将整个图连接起来,想让权值之和整体最小,权值之和可以拆分成各个权值相加,那么当我每条边的权值都是最小的时候,整体就是最小了,所以这是种贪心的策略。故,我先找到所有边,在从小到大排序,最后从头到尾枚举即可,每次枚举出来的都是当前范围内最短边。
2、承接上一条,我每枚举出来一条最短边,一条边连接了两个点。最开始的时候,没有边,都是孤立的点,有n个点集合。每加一条边,就有两个点被连上,点的集合少一个,当加完最后一条边后恰好只剩下一个集合的时候,我们说,简单图就连成了。这种维护连通分量问题,就用并查集维护即可。并查集详见:力扣684冗余连接。
3、值得注意的是,每次枚举出来的边,如果两个端点已经在一个集合里了,就不要在算这条边了,因为我们要的是简单图,任意两个点之间只能有一条边。
朴素Prim算法
1、朴素Prim算法是一套针对点的一个算法,它极其类似Dijkstra算法(Dijkstra算法基本思想详见:图论最短路径专题),都是每次找最小值让整体最小,我们这里说说他们相异之处。
2、朴素Prim算法也是维护两个集合,每次都从未连接点集合里找到一个距离已连接点集合最近的点,直到这里,都和朴素Dijkstra一致。
3、随后要根据这个选中的点,更新其他未连接点到已连接点集合距离,这里就是不同所在!朴素Dijkstra要求的是所有点到源点最短距离,所以dis中存的是到源点最短距离,在更新的时候,看原本的距离和(源点到这次选中点j距离+j->i路径长度)这两个谁小,本质上要把这次枚举的点i连到源点上。
4、但是朴素Prim算法不是,我只想把简单图连上,不是具体到某一个点。图在扩展的时候,每次只需要一条边,连上一个点就行,所以dis存储的就是各个未连起来的点到已连接点集合用的最端边长(权值),在更新的时候,本次选中了j,然后枚举j开头的所有边,其中一个是i,对于dis[i]来讲,需要进行更新,从原来的最小权值和从j->i的权值二者中取最小就行,属于动态的维护过程。
5、值得注意的是,如果找到的最短距离点在已经连接点集合,这种点不要,还是那句话,我要的是简单图,不能重复连接边。
6、如果还不懂,可以参照下方的图片理解:
二叉堆优化Prim
1、思路和二叉堆优化Dijkstra一样,详见:图论最短路径专题,那片博客里也有详细介绍二叉堆封装方法,这里不做赘述。
2、这个优化其实就是把每次找离已连接点集合最近点的工作交给了堆来干,每次由堆来动态维护一个堆顶,在O(1)的时间内就可以得到想要的,别的都一样。
3、每次的更新操作同朴素Prim算法。
4、定义类Node,i代表第i个点,dis代表和已连接集合的距离。
5、值得注意的是,如果找到的最短距离点在已经连接点集合,这种点不要,还是那句话,我要的是简单图,不能重复连接边。
代码
1、因为题目要求,所以需要手动生成所有边,复杂度是O(N^2)
2、本题属于点数少,边数特别多的稠密图,所以要用邻接矩阵存储边。
Kruskal算法
class Node {//类:边
public:
int i;//开头
int j;//结尾
int dis;//距离
bool operator < (const Node & node) {
return dis < node.dis;
}
};
class union_set {//涉及联通分量数维护用并查集
public:
union_set(int n) {
this->n = n;
for (int i = 0; i <= n; i++) {
father[i] = i;
}
}
int find(int k) {
if (k <= 0 || k > n) {
return 0;
}
if (father[k] == k) {
return father[k];
}
father[k] = find(father[k]);
return father[k];
}
void merge(int i, int j) {
father[find(i)] = find(j);
}
private:
int n = 0;
int father[10000];
};
class Solution {
public:
int ans = 0;
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
vector<Node> node;
for (int i = 0; i < n; i++) {//枚举边
for (int j = i + 1; j < n; j++) {//可去掉重复的
int dis = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
node.push_back({ i,j,dis });
}
}
sort(node.begin(), node.end());//排序
int n1 = node.size();
union_set us(n);
for (int i = 0; i < n1; i++) {
Node tmp = node[i];
int x = tmp.i + 1;
int y = tmp.j + 1;
int dis = tmp.dis;
if (us.find(x) != us.find(y)) {//已经连起来的部分不能重复连接
us.merge(x, y);//连接
n--;//连通分量数--
ans += dis;
if (n == 1) {
break;
}
}
else {
continue;
}
}
return ans;
}
};
所有代码均以通过力扣测试
(经过多次测试最短时间为):
(P代表点,E代表边)
时间复杂度:O(ElogE),则主要取决于边数,而与点数无关。本题中E的复杂度O(n^2),所以总体是:O(N ^ 2logN)。
空间复杂度:O(N^2)
朴素Prim算法
1、bool数组默认初始值是true,一定要记住!
class Solution {
public:
int ans = 0;
const int inf = 1 << 29;//极大值,初始化所用
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
vector<vector<int>> edge(1005, vector<int>(1005, inf));
for (int i = 0; i < n; i++) {//建立邻接矩阵
for (int j = 0; j < n; j++) {//无向图
int dis = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
edge[i][j] = dis;
}
}
bool visited[1001] = { false };//默认初始值是true,所以要改初始值,切记
vector<int> dis(1001, inf);//维护距离,初始值为inf
dis[0] = 0;//随便选定一个起点
for (int k = 0; k < n; k++) {
int x = -1;
for (int j = 0; j < n; j++) {//找最近距离
if (!visited[j] && (x == -1 || dis[j] < dis[x])) {
x = j;
}
}
visited[x] = true;
ans += dis[x];
for (int i = 0; i < n; i++) {//更新
if (visited[i]) {//已走过点不算
continue;
}
else if (edge[x][i] != inf) {//值为inf代表没有边
dis[i] = min(dis[i], edge[x][i]);
}
}
}
return ans;
}
};
所有代码均以通过力扣测试
(经过多次测试最短时间为):
(P代表点,E代表边)
时间复杂度:O(P log P),主要取决于点数,而与边数无关。
空间复杂度:O(N^2)
堆优化Prim
class Node {//
public:
int i;//点
int dis;//该点距离已经连接点集合距离
bool operator < (const Node & node) {
return dis < node.dis;
}
};
class pile {//二叉堆
public:
void up(int k) {
int fa = k >> 1;
while (fa >= 1) {
if (node[fa] < node[k]) {
break;
}
swap(node[fa], node[k]);
k = fa;
fa >>= 1;
}
}
void down(int k) {
int son = k << 1;
while (son <= size) {
if (son + 1 <= size && node[son + 1] < node[son]) {
son++;
}
if (node[k] < node[son]) {
break;
}
swap(node[k], node[son]);
k = son;
son <<= 1;
}
}
bool empty() {
if (size == 0) {
return true;
}
return false;
}
Node peak() {
return node[1];
}
void push(Node n) {
node[++size] = n;
up(size);
}
void pop() {
node[1] = node[size--];
down(1);
}
private:
int size = 0;
Node node[1000000];//此图边特别多,要开足够大,为稠密图
};
class Solution {
public:
int ans = 0;
const int inf = 1 << 29;
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
vector<vector<int>> edge(1005, vector<int>(1005, inf));
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {//用的邻接矩阵
int dis = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
edge[i + 1][j + 1] = dis;//堆里面从1开始计数,所以这里也是,+1
}
}
bool visited[2000] = { false };
pile p;
p.push({ 1,0 });//随便定一个起点
while (!p.empty()) {
Node tmp = p.peak();//找最小距离
p.pop();
if (visited[tmp.i]) {//已经连好的不要重复连接
continue;//这样就可以,在后面把所有的状态全加入,原因和Dijkstra堆优化版本里一致
}
visited[tmp.i] = true;
ans += tmp.dis;//算答案
for (int i = 1; i <= n; i++) {//更新,同上
if (visited[i]) {
continue;
}
else if (edge[tmp.i][i] != inf) {
p.push({ i,edge[tmp.i][i] });//这里就看出了,此算法还是以边为主,把同一点所有状态加进去
//最小的先出来,而后做标记已来过,后面再出来就没用了,不冲突
}
}
}
return ans;
}
};
所有代码均以通过力扣测试
(经过多次测试最短时间为):
(P代表点,E代表边)
时间复杂度:O(ElogP+PlogP),此题E复杂度是O(N^2),所以总体复杂度是O(N ^2).
空间复杂度:O(N^2)
Sum Up:
这三种算法没有优劣之分,都是很精妙的想法,只是适用范围不一样。
1、Kruskal算法主要是玩边数,所以在稀疏图即边数较少的情况下推荐使用。
2、朴素Prim算法,主要是针对点数,所以在点数不多,但是边贼多的稠密图的时候(这时候不用Kruskal算法),用这个算法。
3、堆优化Prim算法,同时针对边数点数,建议还是在点数较多,边数较少的稀疏图中使用。
4、没有固定的想法,上述三者都可以用,每次只要保留最符合题意的算法即可。