代码随想录 day53 图论10xx~22 学习

108. 冗余连接

卡码网题目链接(ACM模式)
题目描述
树可以看成是一个图(拥有 n 个节点和 n - 1 条边的连通无环无向图)。
现给定一个拥有 n 个节点(节点编号从 1 到 n)和 n 条边的连通无向图,请找出一条可以删除的边,删除后图可以变成一棵树。
输入描述
第一行包含一个整数 N,表示图的节点个数和边的个数。
后续 N 行,每行包含两个整数 s 和 t,表示图中 s 和 t 之间有一条边。
输出描述
输出一条可以删除的边。如果有多个答案,请删除标准输入中最后出现的那条边。
输入示例
3
1 2
2 3
1 3
输出示例
1 3
提示信息
图中的 1 22 31 3 等三条边在删除后都能使原图变为一棵合法的树。但是 1 3 由于是标准输出里最后出现的那条边,所以输出结果为 1 3
数据范围:
1 <= N <= 1000.

思考

这道题目也是并查集基础题目。
这里我依然降调一下,并查集可以解决什么问题:两个节点是否在一个集合,也可以将两个节点添加到一个集合中。
如果还不了解并查集,可以看这里:并查集理论基础
题目说是无向图,返回一条可以删去的边,使得结果图是一个有着N个节点的树(即:只有一个根节点)。
如果有多个答案,则返回二维数组中最后出现的边。
那么我们就可以从前向后遍历每一条边(因为优先让前面的边连上),边的两个节点如果不在同一个集合,就加入集合(即:同一个根节点)。
节点A 和节点 B 不在同一个集合,那么就可以将两个 节点连在一起。
    0
   /  \
A       B
如果边的两个节点已经出现在同一个集合里,说明着边的两个节点已经连在一起了,再加入这条边一定就出现环了。
如图所示:
    0
   /  \
A  --X-- B
已经判断 节点A 和 节点B 在在同一个集合(同一个根),如果将 节点A 和 节点B 连在一起就一定会出现环。
这个思路清晰之后,代码就很好写了。

code c++ 1

// 并查集C++代码如下:

#include <iostream>
#include <vector>
using namespace std;
int n; // 节点数量
vector<int> father(1001, 0); // 按照节点大小范围定义数组

// 并查集初始化
void init() {
    for (int i = 0; i <= n; ++i) {
        father[i] = i;
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]);
}
// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}
// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

int main() {
    int s, t;
    cin >> n;
    init();
    for (int i = 0; i < n; i++) {
        cin >> s >> t;
        if (isSame(s, t)) {
            cout << s << " " << t << endl;
            return 0;
        } else {
            join(s, t);
        }
    }
}

code python 1


class Simultaneous_Query:
    def __init__(self, N):
        self.father = list(range(N + 1))
    def find(self, u):
        if u == self.father[u]:return u
        else:
            self.father[u] = self.find(self.father[u])
            return self.father[u]

    def join(self, u, v):
        u = self.find(self.father[u])
        v = self.find(self.father[v])
        if u == v:return
        self.father[v] = u

    def is_same(self, u, v):
        u = self.find(self.father[u])
        v = self.find(self.father[v])
        return u == v

def main():
    # N = 4
    # nodes = [[1, 2], [2, 3], [1, 3], [1, 4]]

    N = int(input())
    nodes = []
    for _ in range(N):
        v = [int(i) for i in input().split(' ')]
        nodes.append(v)
    And1 = Simultaneous_Query(N)
    for node in nodes:
        flag = And1.is_same(node[0], node[1])
        if not flag:
            And1.join(node[0], node[1])
        else:
            print(node[0], end=' ')
            print(node[1])

main()

code python 2

N = int(input())
father = [0 for _ in range(N + 1)]

def init():
    for i in range(N+1):
        father[i] = i

def find(u):
    if u == father[u]:return u
    else:
        father[u] = find(father[u])
        return father[u]
def join(u, v):
    u = find(u)
    v = find(v)
    if u == v:return
    else:
        father[v] = u

def isSame(u, v):
    u = find(u)
    v = find(v)
    return u == v

init()
nodes = []
for j in range(N):
    v = [int(i) for i in input().split(' ')]
    nodes.append(v)

for node in nodes:
    if isSame(node[0], node[1]):
        print(node[0], end=' ')
        print(node[1])
    else:
        join(node[0], node[1])

109. 冗余连接II

卡码网题目链接(ACM模式)
题目描述
有向树指满足以下条件的有向图。该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。
有向树拥有 n 个节点和 n - 1 条边。
输入一个有向图,该图由一个有着 n 个节点(节点编号 从 1 到 n),n 条边,请返回一条可以删除的边,使得删除该条边之后该有向图可以被当作一颗有向树。
输入描述
第一行输入一个整数 N,表示有向图中节点和边的个数。
后续 N 行,每行输入两个整数 s 和 t,代表 s 节点有一条连接 t 节点的单向边
输出描述
输出一条可以删除的边,若有多条边可以删除,请输出标准输入中最后出现的一条边。
输入示例
3
1 2
1 3
2 3
输出示例
2 3
提示信息
在删除 2 3 后有向图可以变为一棵合法的有向树,所以输出 2 3
数据范围:
1 <= N <= 1000.

思路

N = int(input())
father = [0 for _ in range(N + 1)]

def init():
    for i in range(N+1):
        father[i] = i

def find(u):
    if u == father[u]:return u
    else:
        father[u] = find(father[u])
        return father[u]
def join(u, v):
    if not isSame(u,v):
        father[v] = u

def isSame(u, v):
    u = find(u)
    v = find(v)
    return u == v

init()
nodes = []
for j in range(N):
    v = [int(i) for i in input().split(' ')]
    nodes.append(v)

for node in nodes:
    if isSame(node[0], node[1]):
        print(node[0], end=' ')
        print(node[1])
    else:
        join(node[0], node[1])

109. 冗余连接II

卡码网题目链接(ACM模式
题目描述
有向树指满足以下条件的有向图。该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。有向树拥有 n 个节点和 n - 1 条边。
输入一个有向图,该图由一个有着 n 个节点(节点编号 从 1 到 n),n 条边,请返回一条可以删除的边,使得删除该条边之后该有向图可以被当作一颗有向树。
输入描述
第一行输入一个整数 N,表示有向图中节点和边的个数。
后续 N 行,每行输入两个整数 s 和 t,代表 s 节点有一条连接 t 节点的单向边
输出描述
输出一条可以删除的边,若有多条边可以删除,请输出标准输入中最后出现的一条边。
输入示例
3
1 2
1 3
2 3
输出示例
2 3
提示信息
在删除 2 3 后有向图可以变为一棵合法的有向树,所以输出 2 3
数据范围:
1 <= N <= 1000.

思路

有向图
本题的本质是 :有一个有向图,是由一颗有向树 + 一条有向边组成的 (所以此时这个图就不能称之为有向树),现在让我们找到那条边 把这条边删了,让这个图恢复为有向树。
还有 “若有多条边可以删除,请输出标准输入中最后出现的一条边”, 这说明在两条边都可以删除的情况下,要删顺序靠后的边!
我们来想一下 有向树的性质,如果是有向树的话,只有根节点入度为0,其他节点入度都为1(因为该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点)。
所以情况一:如果我们找到入度为2的点,那么删一条指向该节点的边就行了。

如图:
  1------->2
   \      /
    \    /
     v  v
      3
 找到了节点3 的入度为2,删 1 -> 3 或者 2 -> 3 。选择删顺序靠后便可。

但 入度为2 还有一种情况,情况二,只能删特定的一条边,如图:

1<-------------2
  \           /
   \         /
    \       /
     \     /
      v   v
4-----> 3

节点3 的入度为 2,但在删除边的时候,只能删 这条边(节点1 -> 节点3),如果删这条边(节点4 -> 节点3),那么删后本图也不是有向树了(因为找不到根节点)。

综上,如果发现入度为2的节点,我们需要判断 删除哪一条边,删除后本图能成为有向树。如果是删哪个都可以,优先删顺序靠后的边。

情况三: 如果没有入度为2的点,说明 图中有环了(注意是有向环)。

如图:

1 <------------ 2
  \           /\
   \         /
    \       /
     \     /
      \|  /
4-----> 3

对于情况二,删掉构成环的边就可以了。

# 写代码
把每条边记录下来,并统计节点入度:
int s, t;
vector<vector<int>> edges;
cin >> n;
vector<int> inDegree(n + 1, 0); // 记录节点入度
for (int i = 0; i < n; i++) {
    cin >> s >> t;
    inDegree[t]++;
    edges.push_back({s, t});
}



n = int(input())
edges = []
inDegree =[0 for _ in range(n +1)]  # 记录入度节点
for i in range(n):
    s, t = [int(v) for v in range(v)]
    inDegree[t] += 1
    edges.append([s, t])

前两种入度为2的情况,一定是删除指向入度为2的节点的两条边其中的一条,如果删了一条,判断这个图是一个树,那么这条边就是答案。
同时注意要从后向前遍历,因为如果两条边删哪一条都可以成为树,就删最后那一条。
代码如下:

vector<int> vec; // 记录入度为2的边(如果有的话就两条边)
// 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
for (int i = n - 1; i >= 0; i--) {
    if (inDegree[edges[i][1]] == 2) {
        vec.push_back(i);
    }
}
if (vec.size() > 0) {
    // 放在vec里的边已经按照倒叙放的,所以这里就优先删vec[0]这条边
    if (isTreeAfterRemoveEdge(edges, vec[0])) {
        cout << edges[vec[0]][0] << " " << edges[vec[0]][1];
    } else {
        cout << edges[vec[1]][0] << " " << edges[vec[1]][1];
    }
    return 0;
}



vec = []  # 记录入度为 2 的边 (如果有的话)
# 找入度为 2 的节点对应的边, 注意倒叙, 因为优先删除最后出现的一条边
for i in range(n-1, -1, -1):
    if isDegree[edges[i][1]] == 2:
        vec.append(i)   # 应该 2 个边
if len(vec) > 0:
    if isTreeAfterRemoveEdge(edges, vec[0]):
        print(edge[vec[0]][0], end= ' ')
        print(edge[vec[0]][1])
    else:
        print(vec[1][0], end= ' ')
        print(vec[1][1])

return 0

再来看情况三,明确没有入度为2的情况,那么一定有向环,找到构成环的边就是要删除的边。

可以定义一个函数,代码如下:

# 在有向图里找到删除的那条边, 使其变成树
// 在有向图里找到删除的那条边,使其变成树
void getRemoveEdge(const vector<vector<int>>& edges)


def getRemoveEdge(edges):
    pass

大家应该知道了,我们要解决本题要实现两个最为关键的函数:

isTreeAfterRemoveEdge() 判断删一个边之后是不是有向树

getRemoveEdge() 确定图中一定有了有向环,那么要找到需要删除的那条边

此时就用到并查集了。

如果还不了解并查集,可以看这里:并查集理论基础(opens new window)

isTreeAfterRemoveEdge() 判断删一个边之后是不是有向树: 将所有边的两端节点分别加入并查集,遇到要 要删除的边则跳过,如果遇到即将加入并查集的边的两端节点 本来就在并查集了,说明构成了环。

如果顺利将所有边的两端节点(除了要删除的边)加入了并查集,则说明 删除该条边 还是一个有向树

getRemoveEdge()确定图中一定有了有向环,那么要找到需要删除的那条边: 将所有边的两端节点分别加入并查集,如果遇到即将加入并查集的边的两端节点 本来就在并查集了,说明构成了环。

本题C++代码如下:(详细注释了)

code c++

#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> father (1001, 0);    // 初始定义最长长度的图
// 并查集初始化
void init() {
    for (int i = 1; i <= n; ++i) {
        father[i] = i;    // 进行初始化
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]);
}
// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u);
    v = find(v);
    if (u == v) return ;
    father[v] = u;
}
// 判断 u 和 v是否找到同一个根
bool same(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 在有向图里找到删除的那条边,使其变成树
void getRemoveEdge(const vector<vector<int>>& edges) {
    init(); // 初始化并查集
    for (int i = 0; i < n; i++) { // 遍历所有的边
        if (same(edges[i][0], edges[i][1])) { // 构成有向环了,就是要删除的边
            cout << edges[i][0] << " " << edges[i][1];
            return;
        } else {
            join(edges[i][0], edges[i][1]);
        }
    }
}

// 删一条边之后判断是不是树
bool isTreeAfterRemoveEdge(const vector<vector<int>>& edges, int deleteEdge) {
    init(); // 初始化并查集
    for (int i = 0; i < n; i++) {
        if (i == deleteEdge) continue;
        if (same(edges[i][0], edges[i][1])) { // 构成有向环了,一定不是树
            return false;
        }
        join(edges[i][0], edges[i][1]);
    }
    return true;
}

int main() {
    int s, t;
    vector<vector<int>> edges;
    cin >> n;
    vector<int> inDegree(n + 1, 0); // 记录节点入度
    for (int i = 0; i < n; i++) {
        cin >> s >> t;
        inDegree[t]++;
        edges.push_back({s, t});
    }

    vector<int> vec; // 记录入度为2的边(如果有的话就两条边)
    // 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
    for (int i = n - 1; i >= 0; i--) {
        if (inDegree[edges[i][1]] == 2) {
            vec.push_back(i);
        }
    }
    if (vec.size() > 0) {
        // 放在vec里的边已经按照倒叙放的,所以这里就优先删vec[0]这条边
        if (isTreeAfterRemoveEdge(edges, vec[0])) {
            cout << edges[vec[0]][0] << " " << edges[vec[0]][1];
        } else {
            cout << edges[vec[1]][0] << " " << edges[vec[1]][1];
        }
        return 0;
    }

    // 处理情况三
    // 明确没有入度为2的情况,那么一定有有向环,找到构成环的边返回就可以了
    getRemoveEdge(edges);
}

code python 1


father = [0 for _ in range(1001)]

def init():
    for i in range(1001):  # 初始化并查集
        father[i] = i

def find(u):   # 并查集里寻根的过程
    if u == father[u]:return u
    else:
        father[u] = find(father[u])
        return father[u]


def join(u, v):   # 将v->u 这条边加入并查集
    u = find(u)
    v = find(v)
    if u == v:return
    else:
        father[v] = u


def isSame(u, v):   # 判断 u 和 v是否找到同一个根
    u = find(u)
    v = find(v)
    return u == v

# 删一条边之后判断是不是树
def isTreeAfterRemovEdge(edges, deleteEdge):
    init()   # 初始化并查集
    for i in range(len(edges)):
        if i == deleteEdge:continue
        if isSame(edges[i][0], edges[i][1]):  # 构成环之后, 一定不是树
            return False
        join(edges[i][0], edges[i][1])
    return True

# 在有向图里找到删除的那条边,使其变成树
def getRemoveEdge(edges):
    init()       # 初始化并查集
    for i in range(len(edges)):   # 遍历所有的边
        if isSame(edges[i][0], edges[i][1]):   # 构成环了就要删除
            print(edges[i][0], end=' ')
            print(edges[i][1])
            return
        join(edges[i][0], edges[i][1])


def main():
    init()
    n = int(input())
    edges = []
    isDegree =[0 for _ in range(n + 1)]
    for i in range(n):
        s, t = [int(i) for i in input().split(' ')]
        isDegree[t] += 1          # 记录每个节点入度的个数
        edges.append([s, t])

    vec = []    # 是否有度为 2 的边  记录入度为2的边(如果有的话就两条边)
    for i in range(n-1, -1, -1):  # 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
        if isDegree[edges[i][1]] == 2:
            vec.append(i)

    if len(vec) > 0:   # 有入度为2的节点
        if isTreeAfterRemovEdge(edges, vec[0]):   # 放在vec里的边已经按照倒叙放的,所以这里就优先删vec[0]这条边
            print(edges[vec[0]][0], end = ' ')
            print(edges[vec[0]][1])
        else:
            print(edges[vec[1]][0], end = ' ')
            print(edges[vec[1]][1])
    # 处理情况三
    #     // 明确没有入度为2的情况,那么一定有有向环,找到构成环的边返回就可以了
    else:
        getRemoveEdge(edges)

main()

prim算法精讲

卡码网:53. 寻宝
题目描述:
在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。国王打算在这些岛屿上建公路,方便运输。
不同岛屿之间,路途距离不同,国王希望你可以规划建公路的方案,如何可以以最短的总公路距离将 所有岛屿联通起来。
给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。以最小化公路建设长度,确保可以链接到所有岛屿。
输入描述:
第一行包含两个整数V 和 E,V代表顶点数,E代表边数 。顶点编号是从1到V。例如:V=2,一个有两个顶点,分别是12。
接下来共有 E 行,每行三个整数 v1,v2 和 val,v1 和 v2 为边的起点和终点,val代表边的权值。
输出描述:
输出联通所有岛屿的最小路径总距离
输入示例:
7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
输出示例:
6

题目读懂

多个岛屿, 每个岛屿之间距离不同, 目标: 希望所有岛屿之间都联通 + 总距离最小。
V: 定点数,岛屿个数。
E: 边数, 岛屿之间有通路。
两个岛屿的连通是无向图,
V1, V2, val: 起点, 终点,权值。

解题思路

本题是最小生成树的模板题,那么我们来讲一讲最小生成树。

最小生成树 可以使用 prim算法 也可以使用 kruskal算法计算出来。

本篇我们先讲解 prim算法。

最小生成树是所有节点的最小连通子图, 即:以最小的成本(边的权值)将图中所有节点链接到一起。

图中有n个节点,那么一定可以用 n - 1 条边将所有节点连接到一起。

那么如何选择 这 n-1 条边 就是 最小生成树算法的任务所在。

例如本题示例中的无向有权图为:

那么在这个图中,如何选取 n-1 条边 使得 图中所有节点连接到一起,并且边的权值和最小呢?

(图中为n为7,即7个节点,那么只需要 n-16条边就可以讲所有顶点连接到一起)

prim算法 是从节点的角度 采用贪心的策略 每次寻找距离 最小生成树最近的节点 并加入到最小生成树中。 (最近: 有边才能近, 最近= 有边+最小距离; 无边就是无穷)

prim算法核心就是三步,我称为prim三部曲,大家一定要熟悉这三步,代码相对会好些很多:

第一步,选距离生成树最近节点

第二步,最近节点加入生成树

第三步,更新非生成树节点到生成树的距离(即更新minDist数组)

现在录友们会对这三步很陌生,不知道这是干啥的,没关系,下面将会画图举例来带大家把这prim三部曲理解到位。

在prim算法中,有一个数组特别重要,这里我起名为:minDist。

刚刚我有讲过 “每次寻找距离最小生成树最近的节点并加入到最小生成树中”,那么如何寻找距离最小生成树最近的节点呢?

这就用到了 minDist 数组, 它用来作什么呢?

minDist数组 用来记录每一个节点距离最小生成树的最近距离。 理解这一点非常重要,这也是 prim算法最核心要点所在,很多录友看不懂prim算法的代码,都是因为没有理解透 这个数组的含义。

接下来,我们来通过一步一步画图,来带大家巩固 prim三部曲 以及 minDist数组 的作用。

(示例中节点编号是从1开始,所以为了让大家看的不晕,minDist数组下标我也从 1 开始计数,下标0 就不使用了,这样 下标和节点标号就可以对应上了,避免大家搞混)

# 1 初始状态

minDist 数组 里的数值初始化为 最大数,因为本题 节点距离不会超过 10000,所以 初始化最大数为 10001就可以。 (// 数组长度=节点个数+1, 但是初始化的值:边值val的最大值)

相信这里录友就要问了,为什么这么做?

现在 还没有最小生成树,默认每个节点距离最小生成树是最大的,这样后面我们在比较的时候,发现更近的距离,才能更新到 minDist 数组上。  (// 最小生成树还没有建立,默认每个节点都距离最小生成树无穷远, 便宜后期找到更近距离时,更新到更小距离)

如图:

开始构造最小生成树

# 2
1、prim三部曲,第一步:选距离生成树最近节点

选择距离最小生成树最近的节点,加入到最小生成树,刚开始还没有最小生成树,所以随便选一个节点加入就好(因为每一个节点一定会在最小生成树里,所以随便选一个就好),那我们选择节点1 (符合遍历数组的习惯,第一个遍历的也是节点12、prim三部曲,第二步:最近节点加入生成树

此时 节点1 已经算最小生成树的节点。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

接下来,我们要更新所有节点距离最小生成树的距离,如图:

注意下标0,我们就不管它了,下标 1 与节点 1 对应,这样可以避免大家把节点搞混。

此时所有非生成树的节点距离 最小生成树(节点1)的距离都已经跟新了 。

节点2 与 节点1 的距离为1,比原先的 距离值10001小,所以更新minDist[2]。
节点3 和 节点1 的距离为1,比原先的 距离值10001小,所以更新minDist[3]。
节点5 和 节点1 的距离为2,比原先的 距离值10001小,所以更新minDist[5]。
注意图中我标记了 minDist数组里更新的权值,是哪两个节点之间的权值,例如 minDist[2] =1 ,这个 1 是 节点1 与 节点2 之间的连线,清楚这一点对最后我们记录 最小生成树的权值总和很重要。

(我在后面依然会不断重复 prim三部曲,可能基础好的录友会感觉有点啰嗦,但也是让大家感觉这三部曲求解的过程)

# 3
1、prim三部曲,第一步:选距离生成树最近节点

选取一个距离 最小生成树(节点1) 最近的非生成树里的节点,节点235 距离 最小生成树(节点1) 最近,选节点 2(其实选 节点3或者节点2都可以,距离一样的)加入最小生成树。

2、prim三部曲,第二步:最近节点加入生成树

此时 节点1 和 节点2,已经算最小生成树的节点。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

接下来,我们要更新节点距离最小生成树的距离,如图:

此时所有非生成树的节点距离 最小生成树(节点1、节点2)的距离都已经跟新了 。

节点3 和 节点2 的距离为2,和原先的距离值1 小,所以不用更新。
节点4 和 节点2 的距离为2,比原先的距离值10001小,所以更新minDist[4]。
节点5 和 节点2 的距离为10001(不连接),所以不用更新。
节点6 和 节点2 的距离为1,比原先的距离值10001小,所以更新minDist[6]# 4
1、prim三部曲,第一步:选距离生成树最近节点

选择一个距离 最小生成树(节点1、节点2) 最近的非生成树里的节点,节点36 距离 最小生成树(节点1、节点2) 最近,选节点3 (选节点6也可以,距离一样)加入最小生成树。

2、prim三部曲,第二步:最近节点加入生成树

此时 节点1 、节点2 、节点3 算是最小生成树的节点。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

接下来更新节点距离最小生成树的距离,如图:

所有非生成树的节点距离 最小生成树(节点1、节点2、节点3 )的距离都已经跟新了 。

节点 4 和 节点 3的距离为 1,和原先的距离值 2 小,所以更新minDist[3]1。
上面为什么我们只比较 节点4 和 节点3 的距离呢?

因为节点3加入 最小生成树后,非 生成树节点 只有 节点 4 和 节点3是链接的,所以需要重新更新一下 节点4距离最小生成树的距离,其他节点距离最小生成树的距离 都不变。

# 5
1、prim三部曲,第一步:选距离生成树最近节点

继续选择一个距离 最小生成树(节点1、节点2、节点3) 最近的非生成树里的节点,为了巩固大家对 minDist数组的理解,这里我再啰嗦一遍:

minDist数组 是记录了 所有非生成树节点距离生成树的最小距离,所以 从数组里我们能看出来,非生成树节点 4 和 节点 6 距离 生成树最近。

任选一个加入生成树,我们选 节点4(选节点6也行) 。

注意,我们根据 minDist数组,选取距离 生成树 最近的节点 加入生成树,那么 minDist数组里记录的其实也是 最小生成树的边的权值(我在图中把权值对应的是哪两个节点也标记出来了)。

如果大家不理解,可以跟着我们下面的讲解,看 minDist数组的变化, minDist数组 里记录的权值对应的哪条边。

理解这一点很重要,因为 最后我们要求 最小生成树里所有边的权值和。

2、prim三部曲,第二步:最近节点加入生成树

此时 节点1、节点2、节点3、节点4 算是 最小生成树的节点。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

接下来更新节点距离最小生成树的距离,如图:

minDist数组已经更新了 所有非生成树的节点距离 最小生成树(节点1、节点2、节点3、节点4 )的距离 。

节点 5 和 节点 4的距离为 1,和原先的距离值 2 小,所以更新minDist[4]1# 6
1、prim三部曲,第一步:选距离生成树最近节点

继续选距离 最小生成树(节点1、节点2、节点3、节点4 )最近的非生成树里的节点,只有 节点 5 和 节点6。

选节点5 (选节点6也可以)加入 生成树。

2、prim三部曲,第二步:最近节点加入生成树

节点1、节点2、节点3、节点4、节点5 算是 最小生成树的节点。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

接下来更新节点距离最小生成树的距离,如图:

minDist数组已经更新了 所有非生成树的节点距离 最小生成树(节点1、节点2、节点3、节点4 、节点5)的距离 。

节点 6 和 节点 5 距离为 2,比原先的距离值 1 大,所以不更新
节点 7 和 节点 5 距离为 1,比原先的距离值 10001小,更新 minDist[7]
# 7
1、prim三部曲,第一步:选距离生成树最近节点

继续选距离 最小生成树(节点1、节点2、节点3、节点4 、节点5)最近的非生成树里的节点,只有 节点 6 和 节点72、prim三部曲,第二步:最近节点加入生成树

选节点6 (选节点7也行,距离一样的)加入生成树。

3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)

节点1、节点2、节点3、节点4、节点5、节点6 算是 最小生成树的节点 ,接下来更新节点距离最小生成树的距离,如图:

这里就不在重复描述了,大家类推,最后,节点7加入生成树,如图:

# 最后
最后我们就生成了一个 最小生成树, 绿色的边将所有节点链接到一起,并且 保证权值是最小的,因为我们在更新 minDist 数组的时候,都是选距离 最小生成树最近的点 加入到树中。

讲解上面的模拟过程的时候,我已经强调多次 minDist数组 是记录了 所有非生成树节点距离生成树的最小距离。

最后,minDist数组 也就是记录的是最小生成树所有边的权值。

我在图中,特别把 每条边的权值对应的是哪两个节点 标记出来(例如minDist[7] = 1,对应的是节点5 和 节点7之间的边,而不是 节点6 和 节点7),为了就是让大家清楚, minDist里的每一个值 对应的是哪条边。

那么我们要求最小生成树里边的权值总和 就是 把 最后的 minDist 数组 累加一起。

以下代码,我对 prim三部曲,做了重点注释,大家根据这三步,就可以 透彻理解prim。

code c++

#include<iostream>
#include<vector>
#include <climits>

using namespace std;
int main() {
    int v, e;
    int x, y, k;
    cin >> v >> e;
    // 填一个默认最大值,题目描述val最大为10000
    vector<vector<int>> grid(v + 1, vector<int>(v + 1, 10001));
    while (e--) {
        cin >> x >> y >> k;
        // 因为是双向图,所以两个方向都要填上
        grid[x][y] = k;
        grid[y][x] = k;

    }
    // 所有节点到最小生成树的最小距离
    vector<int> minDist(v + 1, 10001);

    // 这个节点是否在树里
    vector<bool> isInTree(v + 1, false);

    // 我们只需要循环 n-1次,建立 n - 1条边,就可以把n个节点的图连在一起
    for (int i = 1; i < v; i++) {

        // 1、prim三部曲,第一步:选距离生成树最近节点
        int cur = -1; // 选中哪个节点 加入最小生成树
        int minVal = INT_MAX;
        for (int j = 1; j <= v; j++) { // 1 - v,顶点编号,这里下标从1开始
            //  选取最小生成树节点的条件:
            //  (1)不在最小生成树里
            //  (2)距离最小生成树最近的节点
            if (!isInTree[j] &&  minDist[j] < minVal) {
                minVal = minDist[j];
                cur = j;
            }
        }
        // 2、prim三部曲,第二步:最近节点(cur)加入生成树
        isInTree[cur] = true;

        // 3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)
        // cur节点加入之后, 最小生成树加入了新的节点,那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下
        // 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢
        for (int j = 1; j <= v; j++) {
            // 更新的条件:
            // (1)节点是 非生成树里的节点
            // (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小
            // 很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了
            if (!isInTree[j] && grid[cur][j] < minDist[j]) {
                minDist[j] = grid[cur][j];
            }
        }
    }
    // 统计结果
    int result = 0;
    for (int i = 2; i <= v; i++) { // 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边
        result += minDist[i];
    }
    cout << result << endl;

}

code python 1

def main():
    v, e = [int(v) for v in input().split(' ')]
    grid = [[1001 for _ in range(v+1)] for _ in range(v + 1)] # 填一个默认最大值,题目描述val最大为10000
    for  _ in range(e):
        v1,v2,val = [int(v) for v in input().split(' ')]
        grid[v1][v2] = val
        grid[v2][v1] = val   # 双向图, 所以两个方向都要填上

    # 所有节点到最小生成树的最小距离
    minDist = [1001 for _ in range(v+1)]

    #  这个节点是否在树里
    isInTree = [False for _ in range(v + 1)]

    # 我们只需要循环 n-1次,建立 n - 1条边,就可以把n个节点的图连在一起
    for i in range(1, v):
        #1、prim三部曲,第一步:选距离生成树最近节点
        cur = -1 #  选中哪个节点 加入最小生成树
        minVal = float('Inf')
        for j in range(1, v+1):  #  1 - v,顶点编号,这里下标从1开始
            #  选取最小生成树节点的条件:
            #  (1)不在最小生成树里
            #  (2)距离最小生成树最近的节点
            if (not isInTree[j] and  minDist[j] < minVal):
                minVal = minDist[j]
                cur = j


        # 2、prim三部曲,第二步:最近节点(cur)加入生成树
        isInTree[cur] = True

        # 3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)
        # cur节点加入之后, 最小生成树加入了新的节点,那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下
        # 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢
        for j in range(1, v + 1):
            #  更新的条件:
            # (1)节点是 非生成树里的节点
            # (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小
            #  很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了
            if (not isInTree[j] and grid[cur][j] < minDist[j]):
                minDist[j] = grid[cur][j]

    # 统计结果
    result = 0
    for i in range(2, v + 1): # 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边
        result += minDist[i]
    print(result)

main()

"""
7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
"""

code python 2

def main():
    v,e = [int(v) for v in input().split(' ')]
    grid = [[10001 for _ in range(v+1)] for _ in range(v+1)]
    for _ in range(e):
        x, y, k = [int(v) for v in input().split(' ')]
        grid[x][y] = k
        grid[y][x] = k

    # minDist
    minDist = [10001 for _ in range(v+1)]
    isTree = [False for _ in range(v+1)]   # 该节点是否在最小生成树里面

    for i in range(1, v):   # 遍历 n-1次,找到 n-1条边加入最小生成树
        cur = -1
        minVal = float('Inf')

        # 找距离最小生成树最近的节点
        # 这个节点不在最小生成树里面
        # 这个节点距离最小生成树的距离最小
        for j in range(1, v+1):
            if (not isTree[j] and minVal > minDist[j]):
                minVal = minDist[j]
                cur = j

        # 已经找到了距离最小生成树最近的节点
        # 加入到最小生成树
        isTree[cur] = True

        # 更新非最小生成树的节点到最小生成树的距离
        for j in range(1, v + 1):
            # 这个节点不在最小生成树里面
            # 这个节点当前的边值小于minDist[j]的值
            if (not isTree[j] and grid[cur][j] < minDist[j]):
                minDist[j] = grid[cur][j]

        # 统计结果
        result = 0
        for j in range(2, v+1):
            result += minDist[j]
        print(result)
时间复杂度为 O(n^2),其中 n 为节点数量。

拓展

上面讲解的是记录了最小生成树所有边的权值,如果让打印出来最小生成树的每条边呢? 或者说 要把这个最小生成树画出来呢?

此时我们就需要把最小生成树里每一条边记录下来。

此时有两个问题:

1、用什么结构来记录

2、如何记录

如果记录边,其实就是记录两个节点就可以,两个节点连成一条边。

如何记录两个节点呢?

我们使用一维数组就可以记录。 parent[节点编号] = 节点编号, 这样就把一条边记录下来了。(当然如果节点编号非常大,可以考虑使用map)

使用一维数组记录是有向边,不过我们这里不需要记录方向,所以只关注两条边是连接的就行。

parent数组初始化代码:

vector<int> parent(v + 1, -1);

接下来就是第二个问题,如何记录?

我们再来回顾一下 prim三部曲,

第一步,选距离生成树最近节点

第二步,最近节点加入生成树

第三步,更新非生成树节点到生成树的距离(即更新minDist数组)

大家先思考一下,我们是在第几步,可以记录 最小生成树的边呢?

在本面上半篇 我们讲解过:“我们根据 minDist数组,选组距离生成树最近的节点加入生成树,那么 minDist数组里记录的其实也是最小生成树的边的权值。”

既然 minDist数组 记录了 最小生成树的边,是不是就是在更新minDist数组的时候,去更新parent数组来记录一下对应的边呢。

所以 在 prim三部曲中的第三步,更新 parent数组,代码如下:

for (int j = 1; j <= v; j++) {
    if (!isInTree[j] && grid[cur][j] < minDist[j]) {
        minDist[j] = grid[cur][j];
        parent[j] = cur; // 记录最小生成树的边 (注意数组指向的顺序很重要)
    }
}
代码中注释中,我强调了 数组指向的顺序很重要。 因为不少录友在这里会写成这样: parent[cur] = j 。

这里估计大家会疑惑了,parent[节点编号A] = 节点编号B, 就表示A 和 B 相连,我们这里就不用在意方向,代码中 为什么 只能 parent[j] = cur 而不能 parent[cur] = j 这么写呢?

如果写成 parent[cur] = j,在 for 循环中,有多个 j 满足要求, 那么 parent[cur] 就会被反复覆盖,因为 cur 是一个固定值。

举个例子,cur = 1, 在 for循环中,可能 就 j = 2, j = 3,j =4 都符合条件,那么本来应该记录 节点1 与 节点 2、节点3、节点4相连的。

如果 parent[cur] = j 这么写,最后更新的逻辑是 parent[1] = 2, parent[1] = 3, parent[1] = 4, 最后只能记录 节点1 与节点 4 相连,其他相连情况都被覆盖了。

如果这么写 parent[j] = cur, 那就是 parent[2] = 1, parent[3] = 1, parent[4] = 1 ,这样 才能完整表示出 节点1 与 其他节点都是链接的,才没有被覆盖。

主要问题也是我们使用了一维数组来记录。

如果是二维数组,来记录两个点链接,例如 parent[节点编号A][节点编号B] = 1 ,parent[节点编号B][节点编号A] = 1,来表示 节点A 与 节点B 相连,那就没有上面说的这个注意事项了,当然这么做的话,就是多开辟的内存空间。

以下是输出最小生成树边的代码,不算最后输出, 就额外添加了两行代码,我都注释标记了:

code c++ 2

#include<iostream>
#include<vector>
#include <climits>

using namespace std;
int main() {
    int v, e;
    int x, y, k;
    cin >> v >> e;
    vector<vector<int>> grid(v + 1, vector<int>(v + 1, 10001));
    while (e--) {
        cin >> x >> y >> k;
        grid[x][y] = k;
        grid[y][x] = k;
    }

    vector<int> minDist(v + 1, 10001);
    vector<bool> isInTree(v + 1, false);

    //加上初始化
    vector<int> parent(v + 1, -1);

    for (int i = 1; i < v; i++) {
        int cur = -1;
        int minVal = INT_MAX;
        for (int j = 1; j <= v; j++) {
            if (!isInTree[j] &&  minDist[j] < minVal) {
                minVal = minDist[j];
                cur = j;
            }
        }

        isInTree[cur] = true;
        for (int j = 1; j <= v; j++) {
            if (!isInTree[j] && grid[cur][j] < minDist[j]) {
                minDist[j] = grid[cur][j];

                parent[j] = cur; // 记录边
            }
        }
    }
    // 输出 最小生成树边的链接情况
    for (int i = 1; i <= v; i++) {
        cout << i << "->" << parent[i] << endl;
    }
}

code python 3

def main():
    v, e = [int(v) for v in input().split(' ')]
    grid = [[10001 for _ in range(v + 1)] for _ in range(v + 1)]
    for _ in range(e):
        x, y, val = [int(v) for v in input().split(' ')]
        grid[x][y] = val
        grid[y][x] = val

    # minDist
    minDist = [10001 for _ in range(v + 1)]
    isTree = [False for _ in range(v + 1)]
    parent = [-1 for _ in range(v + 1)]

    for i in range(1, v):
        cur = -1
        minVal = float('Inf')
        for j in range(1, v + 1):
            if (not isTree[j] and minDist[j] < minVal):
                minVal = minDist[j]
                cur = j

        isTree[cur] = True

        for j in range(1, v + 1):
            if (not isTree[j] and grid[cur][j] < minDist[j]):
                minDist[j] = grid[cur][j]
                parent[j] = cur
    result = 0
    for i in range(2, v + 1):
        result += minDist[i]
    print(result)

    for i in range(1, v + 1):
        if parent[i] != -1:
            print(f'{i} -> {parent[i]}')


按照本题示例,代码输入如下:

1->-1
2->1
3->1
4->3
5->4
6->2
7->5

注意,这里是无向图,我在输出上添加了箭头仅仅是为了方便大家看出是边的意思。

大家可以和我们本题最后生成的最小生成树的图 去对比一下 边的链接情况:

绿色的边 是最小生成树,和我们的 输出完全一致。

总结


此时我就把 prim 算法讲解完毕了,我们再来回顾一下。

关于 prim 算法,我自创了三部曲,来帮助大家理解:

第一步,选距离生成树最近节点

第二步,最近节点加入生成树

第三步,更新非生成树节点到生成树的距离(即更新minDist数组)

大家只要理解这三部曲, prim 算法至少是可以写出一个框架出来,然后在慢慢补充细节,这样不至于自己在写prim的时候两眼一抹黑完全凭感觉去写。

这也为什么很多录友感觉 prim 算法比较难,而且每次学会来,隔一段时间又不会写了,主要是没有一个纲领。

理解这三部曲之后,更重要的 就是理解 minDist 数组。

minDist 数组是 prim 算法的灵魂,它帮助 prim 算法完成最重要的一步,就是如何找到距离最小生成树最近的点。

再来帮大家回顾 minDist数组 的含义:记录 每一个节点距离最小生成树的最近距离。

理解 minDist 数组 ,至少大家看prim算法的代码不会懵。

也正是 因为 minDist 数组的作用,我们根据 minDist 数组,选取距离生成树最近的节点加入生成树,那么 minDist 数组里记录的其实也是最小生成树的边的权值。

所以我们求最小生成树的权值和就是计算后的 minDist 数组数值总和。

最后我们拓展了如何求职 最小生成树的每一条边,其实添加的代码很简单,主要是理解 为什么使用 parent数组 来记录边 以及在哪里更新 parent 数组。

同时,因为使用一维数组,数组的下标和数组如何赋值很重要,不要搞反,导致结果被覆盖。

好了,以上为总结,录友们学习愉快。

kruskal算法精讲


卡码网:53. 寻宝

题目描述:

在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。国王打算在这些岛屿上建公路,方便运输。

不同岛屿之间,路途距离不同,国王希望你可以规划建公路的方案,如何可以以最短的总公路距离将 所有岛屿联通起来。

给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。以最小化公路建设长度,确保可以链接到所有岛屿。

输入描述:

第一行包含两个整数V 和 E,V代表顶点数,E代表边数 。顶点编号是从1到V。例如:V=2,一个有两个顶点,分别是12。

接下来共有 E 行,每行三个整数 v1,v2 和 val,v1 和 v2 为边的起点和终点,val代表边的权值。

输出描述:

输出联通所有岛屿的最小路径总距离

输入示例:

7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
输出示例:
6

解题思路

在上一篇 我们讲解了 prim 算法求解 最小生成树,本篇我们来讲解另一个算法:Kruskal,同样可以求最小生成树。

prim 算法是维护节点的集合,而 Kruskal 是维护边的集合。

上来就这么说,大家应该看不太懂,这里是先让大家有这么个印象,带着这个印象在看下文,理解的会更到位一些。

kruscal的思路:

边的权值排序,因为要优先选最小的边加入到生成树里

遍历排序后的边

如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环

如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合

下面我们画图举例说明 kruscal 的工作过程。

依然以示例中,如下这个图来举例。

将图中的边按照权值有小到大排序,这样从贪心的角度来说,优先选权值小的边加入到 最小生成树中。

排序后的边顺序为[(1,2) (4,5) (1,3) (2,6) (3,4) (6,7) (5,7) (1,5) (3,2) (2,4) (5,6)]

(1,2) 表示节点1 与 节点2 之间的边。权值相同的边,先后顺序无所谓。

开始从头遍历排序后的边。

选边(1,2),节点1 和 节点2 不在同一个集合,所以生成树可以添加边(1,2),并将 节点1,节点2 放在同一个集合。

选边(4,5),节点4 和 节点 5 不在同一个集合,生成树可以添加边(4,5) ,并将节点4,节点5 放到同一个集合。

大家判断两个节点是否在同一个集合,就看图中两个节点是否有绿色的粗线连着就行

(这里在强调一下,以下选边是按照上面排序好的边的数组来选择的)

选边(1,3),节点1 和 节点3 不在同一个集合,生成树添加边(1,3),并将节点1,节点3 放到同一个集合。

选边(2,6),节点2 和 节点6 不在同一个集合,生成树添加边(2,6),并将节点2,节点6 放到同一个集合。

选边(3,4),节点3 和 节点4 不在同一个集合,生成树添加边(3,4),并将节点3,节点4 放到同一个集合。

选边(6,7),节点6 和 节点7 不在同一个集合,生成树添加边(6,7),并将 节点6,节点7 放到同一个集合。

选边(5,7),节点5 和 节点7 在同一个集合,不做计算。

选边(1,5),两个节点在同一个集合,不做计算。

后面遍历 边(3,2)(2,4)(5,6) 同理,都因两个节点已经在同一集合,不做计算。

此时 我们就已经生成了一个最小生成树,即:

在上面的讲解中,看图的话 大家知道如何判断 两个节点 是否在同一个集合(是否有绿色的线连在一起),以及如何把两个节点加入集合(就在图中把两个节点连上)

但在代码中,如果将两个节点加入同一个集合,又如何判断两个节点是否在同一个集合呢?

这里就涉及到我们之前讲解的并查集。

我们在并查集开篇的时候就讲了,并查集主要就两个功能:

将两个元素添加到一个集合中

判断两个元素在不在同一个集合

大家发现这正好符合 Kruskal 算法的需求,这也是为什么 我要先讲并查集,再讲 Kruskal。

关于 并查集,我已经在并查集精讲详细讲解过了,所以这里不再赘述,我们直接用。

本题代码如下,已经详细注释:

code c++ 1

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// l,r为 边两边的节点,val为边的数值
struct Edge {
    int l, r, val;
};

// 节点数量
int n = 10001;
// 并查集标记节点关系的数组
vector<int> father(n, -1); // 节点编号是从1开始的,n要大一些

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}

// 并查集的查找操作
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}

// 并查集的加入集合
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

int main() {

    int v, e;
    int v1, v2, val;
    vector<Edge> edges;
    int result_val = 0;
    cin >> v >> e;
    while (e--) {
        cin >> v1 >> v2 >> val;
        edges.push_back({v1, v2, val});
    }

    // 执行Kruskal算法
    // 按边的权值对边进行从小到大排序
    sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
            return a.val < b.val;
    });

    // 并查集初始化
    init();

    // 从头开始遍历边
    for (Edge edge : edges) {
        // 并查集,搜出两个节点的祖先
        int x = find(edge.l);
        int y = find(edge.r);

        // 如果祖先不同,则不在同一个集合
        if (x != y) {
            result_val += edge.val; // 这条边可以作为生成树的边
            join(x, y); // 两个节点加入到同一个集合
        }
    }
    cout << result_val << endl;
    return 0;
}

code python 1

n = 1000
father = [0 for i in range(n+1)]  # 节点数量  并查集标记节点关系的数组  节点编号是从1开始的,n要大一些

# 并查集初始化
def init():
    for i in range(n+1):
        father[i] = i

# 并查集的查找操作
def find(u):
    if father[u] == u: return u
    else:
        father[u] = find(father[u])
        return father[u]

# 并查集的加入集合
def join(u, v):
    u = find(u)  # 寻找u的根
    v = find(v)  # 寻找v的根
    if u == v: return    # 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u


init()
v, e = 7, 11
edges = [[1, 2, 1], [1, 3, 1], [1, 5, 2], [2, 6, 1], [2, 4, 2], [2, 3, 2], [3, 4, 1], [4, 5, 1], [5, 6, 2], [5, 7, 1], [6, 7, 1]]
edges.sort(key=lambda x:x[2])
result = 0
for edge in edges:
    v1 = find(edge[0])
    v2 = find(edge[1])
    if v1 != v2:
        result += edge[2]
        join(v1, v2)

def main():
    v, e = [int(v) for v in input().split(' ')]
    result_val = 0
    edges = []
    for _ in range(e):
        v1 = [int(v) for v in input().split(' ')]
        edges.append(v1)

    # // 执行Kruskal算法
    # // 按边的权值对边进行从小到大排序
    edges.sort(key = lambda x:x[2])

    # 并查集初始化
    init()

    #  从头开始遍历边
    for edge in edges:
        # // 并查集,搜出两个节点的祖先
        x = find(edge[0])
        y = find(edge[1])

        # // 如果祖先不同,则不在同一个集合
        if (x != y):
            result_val += edge[2]    # 这条边可以作为生成树的边
            join(x, y) # 两个节点加入到同一个集合
    print(result_val)

code python 2

n = 1000
father = [0 for i in range(n+1)]

def init():
    for i in range(n+1):
        father[i] = i

def find(u):
    if father[u] == u: return u
    else:
        father[u] = find(father[u])
        return father[u]

def join(u, v):
    u = find(u)
    v = find(v)
    if u == v: return
    father[v] = u


init()
v, e = 7, 11
edges = [[1, 2, 1], [1, 3, 1], [1, 5, 2], [2, 6, 1], [2, 4, 2], [2, 3, 2], [3, 4, 1], [4, 5, 1], [5, 6, 2], [5, 7, 1], [6, 7, 1]]
edges.sort(key=lambda x:x[2])
result = 0
for edge in edges:
    v1 = find(edge[0])
    v2 = find(edge[1])
    if v1 != v2:
        result += edge[2]
        join(v1, v2)

print(result)
时间复杂度:nlogn (快排) + logn (并查集) ,所以最后依然是 nlogn 。n为边的数量。

关于并查集时间复杂度,可以看我在 并查集理论基础 (opens new window)的讲解。

拓展一

如果题目要求将最小生成树的边输出的话,应该怎么办呢?

Kruskal 算法 输出边的话,相对prim 要容易很多,因为 Kruskal 本来就是直接操作边,边的结构自然清晰,不用像 prim一样 需要再节点练成线输出边 (因为prim是对节点操作,而 Kruskal是对边操作,这是本质区别)

本题中,边的结构为:

struct Edge {
    int l, r, val;
};

那么我们只需要找到 在哪里把生成树的边保存下来就可以了。

当判断两个节点不在同一个集合的时候,这两个节点的边就加入到最小生成树, 所以添加边的操作在这里:

vector<Edge> result; // 存储最小生成树的边
// 如果祖先不同,则不在同一个集合
if (x != y) {
    result.push_back(edge); // 记录最小生成树的边
    result_val += edge.val; // 这条边可以作为生成树的边
    join(x, y); // 两个节点加入到同一个集合
}
整体代码如下,为了突出重点,我仅仅将 打印最小生成树的部分代码注释了,大家更容易看到哪些改动。

code c++ 2

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

struct Edge {
    int l, r, val;
};


int n = 10001;

vector<int> father(n, -1);

void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}

int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]);
}

void join(int u, int v) {
    u = find(u);
    v = find(v);
    if (u == v) return ;
    father[v] = u;
}

int main() {

    int v, e;
    int v1, v2, val;
    vector<Edge> edges;
    int result_val = 0;
    cin >> v >> e;
    while (e--) {
        cin >> v1 >> v2 >> val;
        edges.push_back({v1, v2, val});
    }

    sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
            return a.val < b.val;
    });

    vector<Edge> result; // 存储最小生成树的边

    init();

    for (Edge edge : edges) {

        int x = find(edge.l);
        int y = find(edge.r);


        if (x != y) {
            result.push_back(edge); // 保存最小生成树的边
            result_val += edge.val;
            join(x, y);
        }
    }

    // 打印最小生成树的边
    for (Edge edge : result) {
        cout << edge.l << " - " << edge.r << " : " << edge.val << endl;
    }

    return 0;
}

code python3

n = 1000
father = [0 for i in range(n + 1)]  # 节点数量  并查集标记节点关系的数组  节点编号是从1开始的,n要大一些


# 并查集初始化
def init():
    for i in range(n + 1):
        father[i] = i


# 并查集的查找操作
def find(u):
    if father[u] == u:
        return u
    else:
        father[u] = find(father[u])
        return father[u]


# 并查集的加入集合
def join(u, v):
    u = find(u)  # 寻找u的根
    v = find(v)  # 寻找v的根
    if u == v: return  # 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u



def main():
    # v, e = [int(v) for v in input().split(' ')]
    result_val = 0
    result = []
    # edges = []
    # for _ in range(e):
    #     v1 = [int(v) for v in input().split(' ')]
    #     edges.append(v1)

    v, e = 7, 11
    edges = [[1, 2, 1], [1, 3, 1], [1, 5, 2], [2, 6, 1], [2, 4, 2], [2, 3, 2], [3, 4, 1], [4, 5, 1], [5, 6, 2], [5, 7, 1], [6, 7, 1]]

    # // 执行Kruskal算法
    # // 按边的权值对边进行从小到大排序
    edges.sort(key=lambda x: x[2])

    # 并查集初始化
    init()

    #  从头开始遍历边
    for edge in edges:
        # // 并查集,搜出两个节点的祖先
        x = find(edge[0])
        y = find(edge[1])

        # // 如果祖先不同,则不在同一个集合
        if (x != y):
            result_val += edge[2]  # 这条边可以作为生成树的边
            join(x, y)  # 两个节点加入到同一个集合
            result.append(edge[:2])
    print(result_val)
    for edge in result:
        print(f'{edge[0]} -> {edge[1]}')

main()

"""
1 -> 2
1 -> 3
2 -> 6
3 -> 4
4 -> 5
5 -> 7
"""

按照题目中的示例,打印边的输出为:

1 - 2 : 1
1 - 3 : 1
2 - 6 : 1
3 - 4 : 1
4 - 5 : 1
5 - 7 : 1
大家可能发现 怎么和我们 模拟画的图不一样,差别在于 代码生成的最小生成树中 节点5 和 节点7相连的。

其实造成这个差别 是对边排序的时候 权值相同的边先后顺序的问题导致的,无论相同权值边的顺序是什么样的,最后都能得出最小生成树。

拓展二


此时我们已经讲完了 Kruskal 和 prim 两个解法来求最小生成树。

什么情况用哪个算法更合适呢。

Kruskal 与 prim 的关键区别在于,prim维护的是节点的集合,而 Kruskal 维护的是边的集合。 如果一个图中,节点多,但边相对较少,那么使用Kruskal更优。

有录友可能疑惑,一个图里怎么可能节点多,边却少呢?

节点未必一定要连着边那, 例如这个图,大家能明显感受到边没有那么多对吧,但节点数量 和 上述我们讲的例子是一样的。

为什么边少的话,使用 Kruskal 更优呢?

因为 Kruskal 是对边进行排序的后 进行操作是否加入到最小生成树。

边如果少,那么遍历操作的次数就少。

在节点数量固定的情况下,图中的边越少,Kruskal 需要遍历的边也就越少。

而 prim 算法是对节点进行操作的,节点数量越少,prim算法效率就越优。

所以在稀疏图中,用Kruskal更优。 在稠密图中,用prim算法更优。

边数量较少为稀疏图,接近或等于完全图(所有节点皆相连)为稠密图

Prim 算法 时间复杂度为 O(n^2),其中 n 为节点数量,它的运行效率和图中边树无关,适用稠密图。

Kruskal算法 时间复杂度 为 nlogn,其中 n 为边的数量,适用稀疏图。

总结

如果学过了并查集,其实 kruskal 比 prim更好理解一些。

本篇,我们依然通过模拟 Kruskal 算法的过程,来带大家一步步了解其工作过程。

在 拓展一 中讲解了如何输出最小生成树的边。

在 拓展二 中讲解了 prim 和 Kruskal 的区别。

拓扑排序精讲

卡码网:117. 软件构建
题目描述:
某个大型软件项目的构建系统拥有 N 个文件,文件编号从 0 到 N - 1,在这些文件中,某些文件依赖于其他文件的内容,

这意味着如果文件 A 依赖于文件 B,则必须在处理文件 A 之前处理文件 B (0 <= A, B <= N - 1)。请编写一个算法,用于确定文件处理的顺序。

输入描述:

第一行输入两个正整数 M, N。表示 N 个文件之间拥有 M 条依赖关系。

后续 M 行,每行两个正整数 S 和 T,表示 T 文件依赖于 S 文件。

输出描述:

输出共一行,如果能处理成功,则输出文件顺序,用空格隔开。

如果不能成功处理(相互依赖),则输出 -1。

输入示例:

5 4
0 1
0 2
1 3
2 4
输出示例:

0 1 2 3 4

提示信息:

文件依赖关系如下:



所以,文件处理的顺序除了示例中的顺序,还存在

0 2 4 1 3

0 2 1 3 4

等等合法的顺序。

数据范围:

0 <= N <= 10 ^ 5
1 <= M <= 10 ^ 9

拓扑排序的背景

本题是拓扑排序的经典题目。

一聊到 拓扑排序,一些录友可能会想这是排序,不会想到这是图论算法。

其实拓扑排序是经典的图论问题。

先说说 拓扑排序的应用场景。

大学排课,例如 先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条 完整的上课顺序。

拓扑排序在文件处理上也有应用,我们在做项目安装文件包的时候,经常发现 复杂的文件依赖关系, A依赖B,B依赖C,B依赖D,C依赖E 等等。

如果给出一条线性的依赖顺序来下载这些文件呢?

有录友想上面的例子都很简单啊,我一眼能给排序出来。

那如果上面的依赖关系是一百对呢,一千对甚至上万个依赖关系,这些依赖关系中可能还有循环依赖,你如何发现循环依赖呢,又如果排出线性顺序呢。

所以 拓扑排序就是专门解决这类问题的。

概括来说,给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序。

当然拓扑排序也要检测这个有向图 是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。

所以拓扑排序也是图论中判断有向无环图的常用方法。

拓扑排序的思路

拓扑排序指的是一种 解决问题的大体思路, 而具体算法,可能是广搜也可能是深搜。

大家可能发现 各式各样的解法,纠结哪个是拓扑排序?

其实只要能在把 有向无环图 进行线性排序 的算法 都可以叫做 拓扑排序。

实现拓扑排序的算法有两种:卡恩算法(BFS)和DFS

卡恩1962年提出这种解决拓扑排序的思路

一般来说我们只需要掌握 BFS (广度优先搜索)就可以了,清晰易懂,如果还想多了解一些,可以再去学一下 DFS 的思路,但 DFS 不是本篇重点。

接下来我们来讲解BFS的实现思路。

以题目中示例为例如图:

做拓扑排序的话,如果肉眼去找开头的节点,一定能找到 节点0 吧,都知道要从节点0 开始。

但为什么我们能找到 节点0呢,因为我们肉眼看着 这个图就是从 节点0出发的。

作为出发节点,它有什么特征?

你看节点0 的入度 为0 出度为2, 也就是 没有边指向它,而它有两条边是指出去的。

节点的入度表示 有多少条边指向它,节点的出度表示有多少条边 从该节点出发。

所以当我们做拓扑排序的时候,应该优先找 入度为 0 的节点,只有入度为0,它才是出发节点。 理解以上内容很重要!

接下来我给出 拓扑排序的过程,其实就两步:

找到入度为0 的节点,加入结果集

将该节点从图中移除

循环以上两步,直到 所有节点都在图中被移除了。

结果集的顺序,就是我们想要的拓扑排序顺序 (结果集里顺序可能不唯一)

模拟过程

用本题的示例来模拟这一过程:

1、找到入度为0 的节点,加入结果集

2、将该节点从图中移除

1、找到入度为0 的节点,加入结果集

这里大家会发现,节点1 和 节点2 入度都为0, 选哪个呢?

选哪个都行,所以这也是为什么拓扑排序的结果是不唯一的。

2、将该节点从图中移除

1、找到入度为0 的节点,加入结果集

节点2 和 节点3 入度都为0,选哪个都行,这里选节点2

2、将该节点从图中移除

后面的过程一样的,节点3 和 节点4,入度都为0,选哪个都行。

最后结果集为: 0 1 2 3 4 。当然结果不唯一的。

判断有环

如果有 有向环怎么办呢?例如这个图:

这个图,我们只能将入度为0 的节点0 接入结果集。

之后,节点1、2、3、4 形成了环,找不到入度为0 的节点了,所以此时结果集里只有一个元素。

那么如果我们发现结果集元素个数 不等于 图中节点个数,我们就可以认定图中一定有 有向环!

这也是拓扑排序判断有向环的方法。

通过以上过程的模拟大家会发现这个拓扑排序好像不难,还有点简单。

# 写代码

理解思想后,确实不难,但代码写起来也不容易。

为了每次可以找到所有节点的入度信息,我们要在初始话的时候,就把每个节点的入度 和 每个节点的依赖关系做统计。

cin >> n >> m;
vector<int> inDegree(n, 0); // 记录每个文件的入度
vector<int> result; // 记录结果
unordered_map<int, vector<int>> umap; // 记录文件依赖关系

while (m--) {
    // s->t,先有s才能有t
    cin >> s >> t;
    inDegree[t]++; // t的入度加一
    umap[s].push_back(t); // 记录s指向哪些文件
}

n, m = [int(v) for v in input().split(' ')]
inDegree = [0 for _ in range(n)]   # 记录每个文件的入度
umap = {}   # 依赖关系
result = []  # 记录结果
for _ in range(m):
    s, t = [int(v) for v in input().split(' ')]
    inDegree[t] += 1
    umap[s] = umap.get(s, []) + [t]


找入度为0 的节点,我们需要用一个队列放存放。

因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,所以要将这些入度为0的节点放到队列里,依次去处理。

代码如下:

queue<int> que;
for (int i = 0; i < n; i++) {
    // 入度为0的节点,可以作为开头,先加入队列
    if (inDegree[i] == 0) que.push(i);
}
开始从队列里遍历入度为0 的节点,将其放入结果集。


while (que.size()) {
    int  cur = que.front(); // 当前选中的节点
    que.pop();
    result.push_back(cur);
    // 将该节点从图中移除

}


from collections import deque
que = deque()
for i in range(n):
    if inDegree[i] == 0:
        que.append(i)

while que:
    cur = que.popleft()
    result.append(cur)
    if umap[cur]:
        for i in umap[cur]:
            inDegree[i] -= 1
            if inDegree[i] == 0:
                que.append(i)

这里面还有一个很重要的过程,如何把这个入度为0的节点从图中移除呢?

首先我们为什么要把节点从图中移除?

为的是将 该节点作为出发点所连接的边删掉。

删掉的目的是什么呢?

要把 该节点作为出发点所连接的节点的 入度 减一。

如果这里不理解,看上面的模拟过程第一步:

这事节点1 和 节点2 的入度为 1。

将节点0删除后,图为这样:

那么 节点0 作为出发点 所连接的节点的入度 就都做了 减一 的操作。

此时 节点1 和 节点 2 的入度都为0, 这样才能作为下一轮选取的节点。

所以,我们在代码实现的过程中,本质是要将 该节点作为出发点所连接的节点的 入度 减一 就可以了,这样好能根据入度找下一个节点,不用真在图里把这个节点删掉。

该过程代码如下:

while (que.size()) {
    int  cur = que.front(); // 当前选中的节点
    que.pop();
    result.push_back(cur);
    // 将该节点从图中移除
    vector<int> files = umap[cur]; //获取cur指向的节点
    if (files.size()) { // 如果cur有指向的节点
        for (int i = 0; i < files.size(); i++) { // 遍历cur指向的节点
            inDegree[files[i]] --; // cur指向的节点入度都做减一操作
            // 如果指向的节点减一之后,入度为0,说明是我们要选取的下一个节点,放入队列。
            if(inDegree[files[i]] == 0) que.push(files[i]);
        }
    }

}

最后代码如下:

code c++ 1

#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
using namespace std;
int main() {
    int m, n, s, t;
    cin >> n >> m;
    vector<int> inDegree(n, 0); // 记录每个文件的入度

    unordered_map<int, vector<int>> umap;// 记录文件依赖关系
    vector<int> result; // 记录结果

    while (m--) {
        // s->t,先有s才能有t
        cin >> s >> t;
        inDegree[t]++; // t的入度加一
        umap[s].push_back(t); // 记录s指向哪些文件
    }
    queue<int> que;
    for (int i = 0; i < n; i++) {
        // 入度为0的文件,可以作为开头,先加入队列
        if (inDegree[i] == 0) que.push(i);
        //cout << inDegree[i] << endl;
    }
    // int count = 0;
    while (que.size()) {
        int  cur = que.front(); // 当前选中的文件
        que.pop();
        //count++;
        result.push_back(cur);
        vector<int> files = umap[cur]; //获取该文件指向的文件
        if (files.size()) { // cur有后续文件
            for (int i = 0; i < files.size(); i++) {
                inDegree[files[i]] --; // cur的指向的文件入度-1
                if(inDegree[files[i]] == 0) que.push(files[i]);
            }
        }
    }
    if (result.size() == n) {
        for (int i = 0; i < n - 1; i++) cout << result[i] << " ";
        cout << result[n - 1];
    } else cout << -1 << endl;


}

code python 1

from collections import deque
def main():
    n , m = [int(v) for v in input().split(' ')]
    inDegree = [0 for _ in range(n)]  # 记录每个文件的入度
    umap = {i:[] for i in range(n)}     # 记录文件依赖关系
    result = [] # 记录结果
    for i in range(m):
        s, t = [int(v) for v in input().split(' ')]
        # s->t,先有s才能有t
        inDegree[t] += 1          # t的入度加一
        umap[s] = umap.get(s, []) + [t]    # 记录s指向哪些文件


    que = deque()
    for i in range(n):
        #入度为0的文件,可以作为开头,先加入队列
        if inDegree[i] == 0:
            que.append(i)

    while que:
        cur = que.popleft()  # 当前选中的文件
        result.append(cur)
        if umap[cur]:         # cur有后续文件 # 获取该文件指向的文件
            for i in umap[cur]:
                inDegree[i] -= 1   # cur的指向的文件入度-1
                if inDegree[i] == 0:
                    que.append(i)

    if len(result) == n:
        for i in result:
            print(i, end=' ')
        print('\n')
    else:
        print(-1)

main()

dijkstra(朴素版)精讲

卡码网:47. 参加科学大会

【题目描述】

小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。

小明的起点是第一个车站,终点是最后一个车站。然而,途中的各个车站之间的道路状况、交通拥堵程度以及可能的自然因素(如天气变化)等不同,这些因素都会影响每条路径的通行时间。

小明希望能选择一条花费时间最少的路线,以确保他能够尽快到达目的地。

【输入描述】

第一行包含两个正整数,第一个正整数 N 表示一共有 N 个公共汽车站,第二个正整数 M 表示有 M 条公路。

接下来为 M 行,每行包括三个整数,S、E 和 V,代表了从 S 车站可以单向直达 E 车站,并且需要花费 V 单位的时间。

【输出描述】

输出一个整数,代表小明从起点到终点所花费的最小时间。

输入示例

7 9
1 2 1
1 3 4
2 3 2
2 4 5
3 4 2
4 5 3
2 6 4
5 7 4
6 7 9
输出示例:12
【提示信息】
能够到达的情况:
如下图所示,起始车站为 1 号车站,终点车站为 7 号车站,绿色路线为最短的路线,路线总长度为 12,则输出 12。
不能到达的情况:
如下图所示,当从起始车站不能到达终点车站时,则输出 -1。

数据范围:

1 <= N <= 500; 1 <= M <= 5000;

思路

本题就是求最短路,最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。

接下来,我们来详细讲解最短路算法中的 dijkstra 算法。

dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。

需要注意两点:

dijkstra 算法可以同时求 起点到所有节点的最短路径
权值不能为负数
(这两点后面我们会讲到)

如本题示例中的图:

起点(节点1)到终点(节点7) 的最短路径是 图中 标记绿线的部分。

最短路径的权值为12。

其实 dijkstra 算法 和 我们之前讲解的prim算法思路非常接近,如果大家认真学过prim算法,那么理解 Dijkstra 算法会相对容易很多。(这也是我要先讲prim再讲dijkstra的原因)

dijkstra 算法 同样是贪心的思路,不断寻找距离 源点最近的没有访问过的节点。

这里我也给出 dijkstra三部曲:

第一步,选源点到哪个节点近且该节点未被访问过

第二步,该最近节点被标记访问过

第三步,更新非访问节点到源点的距离(即更新minDist数组)

大家此时已经会发现,这和prim算法 怎么这么像呢。

我在prim算法讲解中也给出了三部曲。 prim 和 dijkstra 确实很像,思路也是类似的,这一点我在后面还会详细来讲。

在dijkstra算法中,同样有一个数组很重要,起名为:minDist。

minDist数组 用来记录 每一个节点距离源点的最小距离。

理解这一点很重要,也是理解 dijkstra 算法的核心所在。

大家现在看着可能有点懵,不知道什么意思。

没关系,先让大家有一个印象,对理解后面讲解有帮助。

我们先来画图看一下 dijkstra 的工作过程,以本题示例为例: (以下为朴素版dijkstra的思路)

(示例中节点编号是从1开始,所以为了让大家看的不晕,minDist数组下标我也从 1 开始计数,下标0 就不使用了,这样 下标和节点标号就可以对应上了,避免大家搞混)

# 朴素版dijkstra

# 模拟过程

0、初始化

minDist数组数值初始化为int最大值。

这里在强点一下 minDist数组的含义:记录所有节点到源点的最短路径,那么初始化的时候就应该初始为最大值,这样才能在后续出现最短路径的时候及时更新。

(图中,max 表示默认值,节点0 不做处理,统一从下标1 开始计算,这样下标和节点数值统一, 方便大家理解,避免搞混)

源点(节点1) 到自己的距离为0,所以 minDist[1] = 0

此时所有节点都没有被访问过,所以 visited数组都为0

以下为dijkstra 三部曲

1、选源点到哪个节点近且该节点未被访问过

源点距离源点最近,距离为0,且未被访问。

2、该最近节点被标记访问过

标记源点访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。

源点到节点2的最短距离为1,小于原minDist[2]的数值max,更新minDist[2] = 1
源点到节点3的最短距离为4,小于原minDist[3]的数值max,更新minDist[4] = 4
可能有录友问:为啥和 minDist[2] 比较?

再强调一下 minDist[2] 的含义,它表示源点到节点2的最短距离,那么目前我们得到了 源点到节点2的最短距离为1,小于默认值max,所以更新。 minDist[3]的更新同理

1、选源点到哪个节点近且该节点未被访问过

未访问过的节点中,源点到节点2距离最近,选节点2

2、该最近节点被标记访问过

节点2被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点6 、 节点3 和 节点4的距离。

为什么更新这些节点呢? 怎么不更新其他节点呢?

因为 源点(节点1)通过 已经计算过的节点(节点2) 可以链接到的节点 有 节点3,节点4和节点6.

更新 minDist数组:

源点到节点6的最短距离为5,小于原minDist[6]的数值max,更新minDist[6] = 5
源点到节点3的最短距离为3,小于原minDist[3]的数值4,更新minDist[3] = 3
源点到节点4的最短距离为6,小于原minDist[4]的数值max,更新minDist[4] = 6
1、选源点到哪个节点近且该节点未被访问过

未访问过的节点中,源点距离哪些节点最近,怎么算的?

其实就是看 minDist数组里的数值,minDist 记录了 源点到所有节点的最近距离,结合visited数组筛选出未访问的节点就好。

从 上面的图,或者 从minDist数组中,我们都能看出 未访问过的节点中,源点(节点1)到节点3距离最近。

2、该最近节点被标记访问过

节点3被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组:

更新 minDist数组:

源点到节点4的最短距离为5,小于原minDist[4]的数值6,更新minDist[4] = 5
1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,有节点4 和 节点6,距离源点距离都是 5 (minDist[4] = 5,minDist[6] = 5) ,选哪个节点都可以。

2、该最近节点被标记访问过

节点4被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点4的加入,那么源点可以链接到节点5 所以更新minDist数组:

源点到节点5的最短距离为8,小于原minDist[5]的数值max,更新minDist[5] = 8
1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点6,距离源点距离是 5 (minDist[6] = 52、该最近节点被标记访问过

节点6 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点6的加入,那么源点可以链接到节点7 所以 更新minDist数组:

源点到节点7的最短距离为14,小于原minDist[7]的数值max,更新minDist[7] = 14
1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点5,距离源点距离是 8 (minDist[5] = 82、该最近节点被标记访问过

节点5 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点5的加入,那么源点有新的路径可以链接到节点7 所以 更新minDist数组:

源点到节点7的最短距离为12,小于原minDist[7]的数值14,更新minDist[7] = 12
1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点7(终点),距离源点距离是 12 (minDist[7] = 122、该最近节点被标记访问过

节点7 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

节点7加入,但节点7到节点7的距离为0,所以 不用更新minDist数组

最后我们要求起点(节点1) 到终点 (节点7)的距离。

再来回顾一下minDist数组的含义:记录 每一个节点距离源点的最小距离。

那么起到(节点1)到终点(节点7)的最短距离就是 minDist[7] ,按上面举例讲解来说,minDist[7] = 12,节点1 到节点7的最短路径为 12。

路径如图:

在上面的讲解中,每一步 我都是按照 dijkstra 三部曲来讲解的,理解了这三部曲,代码也就好懂的。

#代码实现
本题代码如下,里面的 三部曲 我都做了注释,大家按照我上面的讲解 来看如下代码:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        grid[p1][p2] = val;
    }

    int start = 1;
    int end = n;

    // 存储从源点到每个节点的最短距离
    std::vector<int> minDist(n + 1, INT_MAX);

    // 记录顶点是否被访问过
    std::vector<bool> visited(n + 1, false);

    minDist[start] = 0;  // 起始点到自身的距离为0

    for (int i = 1; i <= n; i++) { // 遍历所有节点

        int minVal = INT_MAX;
        int cur = 1;

        // 1、选距离源点最近且未访问过的节点
        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }

        visited[cur] = true;  // 2、标记该节点已被访问

        // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组)
        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
            }
        }

    }

    if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径

}
时间复杂度:O(n^2)
空间复杂度:O(n^2)
#debug方法
写这种题目难免会有各种各样的问题,我们如何发现自己的代码是否有问题呢?

最好的方式就是打日志,本题的话,就是将 minDist 数组打印出来,就可以很明显发现 哪里出问题了。

每次选择节点后,minDist数组的变化是否符合预期 ,是否和我上面讲的逻辑是对应的。

例如本题,如果想debug的话,打印日志可以这样写:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        grid[p1][p2] = val;
    }

    int start = 1;
    int end = n;

    std::vector<int> minDist(n + 1, INT_MAX);

    std::vector<bool> visited(n + 1, false);

    minDist[start] = 0;
    for (int i = 1; i <= n; i++) {

        int minVal = INT_MAX;
        int cur = 1;


        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }

        visited[cur] = true;

        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
            }
        }

        // 打印日志:
        cout << "select:" << cur << endl;
        for (int v = 1; v <= n; v++) cout <<  v << ":" << minDist[v] << " ";
        cout << endl << endl;;

    }
    if (minDist[end] == INT_MAX) cout << -1 << endl;
    else cout << minDist[end] << endl;

}

打印后的结果:

select:1
1:0 2:1 3:4 4:2147483647 5:2147483647 6:2147483647 7:2147483647

select:2
1:0 2:1 3:3 4:6 5:2147483647 6:5 7:2147483647

select:3
1:0 2:1 3:3 4:5 5:2147483647 6:5 7:2147483647

select:4
1:0 2:1 3:3 4:5 5:8 6:5 7:2147483647

select:6
1:0 2:1 3:3 4:5 5:8 6:5 7:14

select:5
1:0 2:1 3:3 4:5 5:8 6:5 7:12

select:7
1:0 2:1 3:3 4:5 5:8 6:5 7:12
打印日志可以和上面我讲解的过程进行对比,每一步的结果是完全对应的。

所以如果大家如果代码有问题,打日志来debug是最好的方法

#如何求路径
如果题目要求把最短路的路径打印出来,应该怎么办呢?

这里还是有一些“坑”的,本题打印路径和 prim 打印路径是一样的,我在 prim算法精讲 【拓展】中 已经详细讲解了。

在这里就不再赘述。

打印路径只需要添加 几行代码, 打印路径的代码我都加上的日志,如下:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        grid[p1][p2] = val;
    }

    int start = 1;
    int end = n;

    std::vector<int> minDist(n + 1, INT_MAX);

    std::vector<bool> visited(n + 1, false);

    minDist[start] = 0; 

    //加上初始化
    vector<int> parent(n + 1, -1);

    for (int i = 1; i <= n; i++) {

        int minVal = INT_MAX;
        int cur = 1;

        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }

        visited[cur] = true;

        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
                parent[v] = cur; // 记录边
            }
        }

    }

    // 输出最短情况
    for (int i = 1; i <= n; i++) {
        cout << parent[i] << "->" << i << endl;
    }
}
打印结果:

-1->1
1->2
2->3
3->4
4->5
2->6
5->7
对应如图:

#出现负数
如果图中边的权值为负数,dijkstra 还合适吗?

看一下这个图: (有负权值)

节点1 到 节点5 的最短路径 应该是 节点1 -> 节点2 -> 节点3 -> 节点4 -> 节点5

那我们来看dijkstra 求解的路径是什么样的,继续dijkstra 三部曲来模拟 :(dijkstra模拟过程上面已经详细讲过,以下只模拟重要过程,例如如何初始化就省略讲解了)

初始化:

1、选源点到哪个节点近且该节点未被访问过

源点距离源点最近,距离为0,且未被访问。

2、该最近节点被标记访问过

标记源点访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。

源点到节点2的最短距离为100,小于原minDist[2]的数值max,更新minDist[2] = 100
源点到节点3的最短距离为1,小于原minDist[3]的数值max,更新minDist[4] = 1
1、选源点到哪个节点近且该节点未被访问过

源点距离节点3最近,距离为1,且未被访问。

2、该最近节点被标记访问过

标记节点3访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:



由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组:

源点到节点4的最短距离为2,小于原minDist[4]的数值max,更新minDist[4] = 2
1、选源点到哪个节点近且该节点未被访问过

源点距离节点4最近,距离为2,且未被访问。

2、该最近节点被标记访问过

标记节点4访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点4的加入,那么源点可以有新的路径链接到节点5 所以更新minDist数组:

源点到节点5的最短距离为3,小于原minDist[5]的数值max,更新minDist[5] = 5
1、选源点到哪个节点近且该节点未被访问过

源点距离节点5最近,距离为3,且未被访问。

2、该最近节点被标记访问过

标记节点5访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

节点5的加入,而节点5 没有链接其他节点, 所以不用更新minDist数组,仅标记节点5被访问过了

1、选源点到哪个节点近且该节点未被访问过

源点距离节点2最近,距离为100,且未被访问。

2、该最近节点被标记访问过

标记节点2访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

至此dijkstra的模拟过程就结束了,根据最后的minDist数组,我们求 节点1 到 节点5 的最短路径的权值总和为 3,路径: 节点1 -> 节点3 -> 节点4 -> 节点5

通过以上的过程模拟,我们可以发现 之所以 没有走有负权值的最短路径 是因为 在 访问 节点 2 的时候,节点 3 已经访问过了,就不会再更新了。

那有录友可能会想: 我可以改代码逻辑啊,访问过的节点,也让它继续访问不就好了?

那么访问过的节点还能继续访问会不会有死循环的出现呢?控制逻辑不让其死循环?那特殊情况自己能都想清楚吗?(可以试试,实践出真知)

对于负权值的出现,大家可以针对某一个场景 不断去修改 dijkstra 的代码,但最终会发现只是 拆了东墙补西墙,对dijkstra的补充逻辑只能满足某特定场景最短路求解。

对于求解带有负权值的最短路问题,可以使用 Bellman-Ford 算法 ,我在后序会详细讲解。

#dijkstra与prim算法的区别
这里再次提示,需要先看我的 prim算法精讲 ,否则可能不知道我下面讲的是什么。

大家可以发现 dijkstra的代码看上去 怎么和 prim算法这么像呢。

其实代码大体不差,唯一区别在 三部曲中的 第三步: 更新minDist数组

因为prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求 非访问节点到源点的最小距离。

prim 更新 minDist数组的写法:

for (int j = 1; j <= v; j++) {
    if (!isInTree[j] && grid[cur][j] < minDist[j]) {
        minDist[j] = grid[cur][j];
    }
}
因为 minDist表示 节点到最小生成树的最小距离,所以 新节点cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。

dijkstra 更新 minDist数组的写法:

for (int v = 1; v <= n; v++) {
    if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
        minDist[v] = minDist[cur] + grid[cur][v];
    }
}
因为 minDist表示 节点到源点的最小距离,所以 新节点 cur 的加入,需要使用 源点到cur的距离 (minDist[cur]+ cur 到 节点 v 的距离 (grid[cur][v]),才是 源点到节点v的距离。

此时大家可能不禁要想 prim算法 可以有负权值吗?

当然可以!

录友们可以自己思考思考一下,这是为什么?

这里我提示一下:prim算法只需要将节点以最小权值和链接在一起,不涉及到单一路径。

#总结
本篇,我们深入讲解的dijkstra算法,详细模拟其工作的流程。

这里我给出了 dijkstra 三部曲 来 帮助大家理解 该算法,不至于 每次写 dijkstra 都是黑盒操作,没有框架没有章法。

在给出的代码中,我也按照三部曲的逻辑来给大家注释,只要理解这三部曲,即使 过段时间 对 dijkstra 算法有些遗忘,依然可以写出一个框架出来,然后再去调试细节。

对于图论算法,一般代码都比较长,很难写出代码直接可以提交通过,都需要一个debug的过程,所以 学习如何debug 非常重要!

这也是我为什么 在本文中 单独用来讲解 debug方法。

本题求的是最短路径和是多少,同时我们也要掌握 如何把最短路径打印出来。

我还写了大篇幅来讲解 负权值的情况, 只有画图带大家一步一步去 看 出现负权值 dijkstra的求解过程,才能帮助大家理解,问题出在哪里。

如果我直接讲:是因为访问过的节点 不能再访问,导致错过真正的最短路,我相信大家都不知道我在说啥。

最后我还讲解了 dijkstra 和 prim 算法的 相同 与 不同之处, 我在图论的讲解安排中 先讲 prim算法 再讲 dijkstra 是有目的的, 理解这两个算法的相同与不同之处 有助于大家学习的更深入。

而不是 学了 dijkstra 就只看 dijkstra, 算法之间 都是有联系的,多去思考 算法之间的相互联系,会帮助大家思考的更深入,掌握的更彻底。

本篇写了这么长,我也只讲解了 朴素版dijkstra,关于 堆优化dijkstra,我会在下一篇再来给大家详细讲解。
  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值