342 道路与航线(单源最短路径 + 拓扑排序)

1. 问题描述:

农夫约翰正在一个新的销售区域对他的牛奶销售方案进行调查。他想把牛奶送到 T 个城镇,编号为 1∼T。这些城镇之间通过 R 条道路 (编号为 1 到 R) 和 P 条航线 (编号为 1 到 P) 连接。每条道路 i 或者航线 i 连接城镇 Ai 到 Bi,花费为 Ci。对于道路,0 ≤ Ci ≤ 10,000;然而航线的花费很神奇,花费 Ci 可能是负数(−10,000≤ Ci  ≤ 10,000)。道路是双向的,可以从 Ai 到 Bi,也可以从 Bi 到 Ai,花费都是 Ci。然而航线与之不同,只可以从 Ai 到 Bi。事实上,由于最近恐怖主义太嚣张,为了社会和谐,出台了一些政策:保证如果有一条航线可以从 Ai 到 Bi,那么保证不可能通过一些道路和航线从 Bi 回到 Ai。由于约翰的奶牛世界公认十分给力,他需要运送奶牛到每一个城镇。他想找到从发送中心城镇 S 把奶牛送到每个城镇的最便宜的方案。

输入格式

第一行包含四个整数 T,R,P,S。接下来 R 行,每行包含三个整数(表示一个道路)Ai,Bi,Ci。接下来 P 行,每行包含三个整数(表示一条航线)Ai,Bi,Ci。

输出格式

第 1..T 行:第 i 行输出从 S 到达城镇 i 的最小花费,如果不存在,则输出 NO PATH。

数据范围

1 ≤ T ≤ 25000,
1 ≤ R,P ≤ 50000,
1 ≤ Ai,Bi,S ≤ T

输入样例:

6 3 3 4
1 2 5
3 4 5
5 6 10
3 5 -100
4 6 -100
1 3 -10

输出样例:

NO PATH
NO PATH
5
0
-95
-100
来源:https://www.acwing.com/problem/content/344/

2. 思路分析:

1. 分析题目可以知道题目中的路径可以分为两大类,分别为:

  • 道路:双向,边权非负
  • 航线:单向,边权可正可负

问题是求解从起点s到其余点的最短距离,如果起点到某个点不可达,那么输出"NO PATH"。由于图的权重存在负权边所以最容易想到的是使用spfa来求解单源最短路径,但是这道题目会卡spfa,spfa最坏情况下的时间复杂度为O(nm),对于这道题目来说n = 25000,m = 50000 * 2,所以最坏情况下也会超时,有木有一种正确但是又可以保证时间复杂度的算法呢?其实是有的。由题目知,道路与道路之间的权重都是双向且是非负的,而航线都是单向的并且可正可负,我们可以定义道路与道路之间的所有节点以及他们的联系为一个团(联通块),那么团内部的所有所有路径都是非负的,团与团之间的航线可正可负,为什么团内部的的权重是非负的呢?因为他们属于道路与道路的联系,如果有航线那么他们的联系都是单向的,而此时都是双向的联系所以团内部一定是道路,团与团之间的航线的权重是可正可负的。对于非负的边(团内部)我们可以使用dijkstra算法,因为数据规模比较大所以可以使用堆优化版本的dijkstra算法;怎么样处理负权边呢?可以发现团与团之间是单向的,而且不存在环,所以属于一个拓扑图,所以我们可以使用拓扑序线性扫描每一个团,在每一个团内部做朴素版本的dijkstra算法即可,使用拓扑序线性扫描每一个团的结果更新最短距离的方法一定是正确的(拓扑序代表的就是一个转移的方向)。

2. 下面是具体的实现步骤:

  • 先输入所有双向道路,然后使用dfs(bfs或者并查集也可以)标记同一个联通块中的所有点,计算两个数组,其中idx[]存储每一个点属于哪一个联通块,block[]存储对应编号的联通块的所有点的编号(后面可以在联通块内部做dijkstra算法),使用全局变量bid来表示每一个联通块的编号,联通块的编号与节点的编号都是从1开始的
  • 输入所有的单向航线,同时统计每个联通块的入度
  • 按照拓扑序处理每一个联通块,先将入度为0的联通块加入到队列q中
  • 每一次从队头取出一个联通块的编号bid
  • 将该block[bid]中的所有点加入到堆中并且使用朴素版本的dijkstra算法更新联通块内部的最短距离
  • 每一次取出堆中最小距离的点ver,然后遍历ver的所有邻接点,如果idx[ver] == idx[j]如果j能够被更新那么将j加入到堆中,若idx[ver] != idx[j]则将这个联通块的入度减1如果入度变成了0那么将其加入到拓扑排序的队列q中

这道题目将好多知识点都串在了一起,比如最短路径算法,拓扑排序,图中连通性的判断(dfs,bfs,并查集都可以判断图的连通性),涉及到的细节很多,而且由于使用的语言不一样表达的数据结构也不一样,对于python语言表达比较复杂的数据结构还是比较方便的,可以通过列表,元组,字典的嵌套表达很多复杂的数据结构,但是一个很明显的缺点的pyhon运行比较慢,在初始化一个比较大的列表的时候耗时比较大,并且还需要注意的一个问题是python3的递归次数最大在1000次左右,需要额外设置一下当前递归调用最大次数否则会发生爆栈的问题(运行时错误)。

3. 代码如下:

from typing import List
import collections
import heapq
import sys


class Solution:
    # 每个点的编号的和团的编号bid的都是从1开始的
    # bid表示每一个团的id编号, block表示每个团内部所有点的编号
    bid, block = None, None
    # idx记录每一个点的属于的团的bid, din记录每一个团的入度, dis距离dijkstra算法的距离列表
    idx, din, dis = None, None, None
    # q用来记录入度为0的团, 需要声明为全局变量(这里双端队列q是为拓扑排序服务的)
    q = None

    # dijkstra算法求解当前团的最短距离, 团内部的所有权重是大于等于0的
    def dijkstra(self, n: int, bid: int, g: List[List[int]]):
        # 由于是堆优化版的dijkstra写法所以需要借助于一个堆来写, python中的heapq模块可以将一个列表调整为一个堆, 模拟堆的相关操作
        q = list()
        # 表示节点编号是否在队列中
        vis = [0] * (n + 1)
        for u in self.block[bid]:
            # 将当前团的所有节点的dis和u作为元组加入到小根堆中
            heapq.heappush(q, (self.dis[u], u))
        while q:
            # p为一个元组, p[0]表示起点s到p[0]的距离, p[1]表示当前的节点编号
            p = heapq.heappop(q)
            # 如果在堆中那么跳过
            if vis[p[1]] == 1: continue
            # 标记当前的节点已使用, 也即在堆中
            vis[p[1]] = 1
            for next in g[p[1]]:
                # 由于当前团的点有联向另外一个团的航线所以当有航线的时候那么指向节点的所在团的入度减1
                self.din[self.idx[next[0]]] -= 1
                # 两个点不在一个团并且另外一个团的入度已经为0了那么说明需要加入到拓扑排序的双端队列中
                if self.idx[p[1]] != self.idx[next[0]] and self.din[self.idx[next[0]]] == 0: self.q.append(
                    self.idx[next[0]])
                # 如果距离更小那么更新最小距离
                if self.dis[next[0]] > self.dis[p[1]] + next[1]:
                    self.dis[next[0]] = self.dis[p[1]] + next[1]
                    # 只有是团内部的点才可以做dijkstra算法所以在团内部的时候将点加入到堆中
                    if self.idx[p[1]] == self.idx[next[0]]:
                        heapq.heappush(q, (self.dis[next[0]], next[0]))

    def topSort(self, n: int, s: int, g):
        # 初始化起点的距离
        self.dis[s] = 0
        # 判断每一个团的入度, 注意bid编号是从1开始的
        for i in range(1, self.bid + 1):
            # 统计入度为0的点
            if self.din[i] == 0:
                self.q.append(i)
        while self.q:
            t = self.q.popleft()
            self.dijkstra(n, t, g)

    # u表示当前联通块中的递归到的节点编号, 所有由当前u连起来的都属于同一个团, dfs找到同一个团的所有点并且将其记录在block[bid]中
    def dfs(self, u: int, g: List[List[int]]):
        self.idx[u] = self.bid
        self.block[self.bid].append(u)
        for next in g[u]:
            if self.idx[next[0]] == 0:
                self.dfs(next[0], g)

    # 道路内部表示一个团, 团与团之间的属于一个航线
    def process(self):
        # n表示节点数目, mr表示道路的数目, mp表示航线的数目, s表示起点
        n, mr, mp, s = map(int, input().split())
        # 处理道路的时候使用dfs来标记团内部的点以及记录这个团内部的所有点的编号, 设置成全局变量可能会比较方便一点
        # 每一个block[i]都是一个列表
        self.block = [list() for i in range(n + 1)]
        g = [list() for i in range(n + 2)]
        for i in range(mr):
            a, b, c = map(int, input().split())
            # 道路是双向的, 并且边权是正的
            g[a].append((b, c))
            g[b].append((a, c))
        # 使用dfs来标记每一个团, 标记团中的每一个点
        self.idx = [0] * (n + 1)
        self.bid = 0
        for i in range(1, n + 1):
            if self.idx[i] == 0:
                self.bid += 1
                self.dfs(i, g)
        # 输入m条航线, 航线的边权可以是负的
        din = [0] * (n + 1)
        self.din = din
        for i in range(mp):
            a, b, c = map(int, input().split())
            # b节点对应的团的入度加1, 这里对dbin的先修改会直接更新到全局变量声明的din, 两者是一样的
            din[self.idx[b]] += 1
            g[a].append((b, c))
        INF = 10 ** 10
        self.dis = [INF] * (n + 1)
        # 初始化拓扑排序需要用到的双端队列
        self.q = collections.deque()
        self.topSort(n, s, g)
        for i in range(1, n + 1):
            # 由于有的点是起点无法到达的并且有航线到达它所以会小于INF那么当dis[i]小于INF的某一个数的时候说明无法到达, 这里可以取INF / 2
            if self.dis[i] > INF // 2:
                print("NO PATH")
            else:
                print(self.dis[i])


if __name__ == "__main__":
    # 由于每一个团的节点个数可能大于1000, 而python最大的递归调用次数为1000所以不设置最大递归调用次数会导致爆栈发生运行时异常, 这道题目涉及到的细节好多啊, 一旦某些地方写错了答案就是错的
    sys.setrecursionlimit(60000)
    Solution().process()
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值