Skip to content

Portal - 题解

标签与难度

标签: 动态规划, 图论, 最短路, Floyd-Warshall, DP 难度: 2200

题目大意喵~

你好呀,指挥官!我们现在在一个大工厂里探险,这个工厂可以看作是一个有 NN 个顶点和 MM 条边的图,每条边都有自己的长度。我们有 KK 个任务要按顺序完成。

ii 个任务是:先去顶点 aia_i 拿一个货物,然后把它送到顶点 bib_i。我们一开始在顶点 1。

我们手上有一把神奇的传送枪!只要我们站在某个顶点 uu,就可以在脚下开一个传送门。当工厂里同时存在两个传送门时(比如在 uuvv),我们就可以在 uuvv 之间瞬移,不花费任何时间和路程,就像有一条长度为 0 的边一样,喵~

我们还有一个遥控器,可以随时随地关闭一个已经存在的传送门。但是要注意,工厂里最多只能同时存在两个传送门哦。所以如果已经有两个了,想在新的地方开一个,就必须先用遥控器关掉一个旧的。

我们的目标是,计算出完成所有 KK 个任务所需行走的最小总距离,喵!

解题思路分析

这真是一道有趣的谜题呢,喵~ 结合了图论和动态规划,让我的爪子都兴奋起来了!我们一步一步来拆解它吧。

第一步:简化问题和预处理

首先,我们需要在图上频繁地计算两点之间的最短距离。因为这是一个稠密图或者点数不多的图(N300N \le 300),我们可以用 Floyd-Warshall 算法来预处理出所有顶点对之间的最短路径。设 dist[i][j] 为从顶点 ii 到顶点 jj 的最短距离。Floyd-Warshall 的时间复杂度是 O(N3)O(N^3),对于这道题来说是完全可以接受的。

接下来,我们把任务序列串起来。我们从顶点 1 出发,然后要依次访问 a_1, b_1, a_2, b_2, ..., a_k, b_k。我们可以把这个访问序列叫做 PPP0=1P_0 = 1P1=a1,P2=b1P_1 = a_1, P_2 = b_1P3=a2,P4=b2P_3 = a_2, P_4 = b_2 ... P2k1=ak,P2k=bkP_{2k-1} = a_k, P_{2k} = b_k 所以,我们的问题就变成了要按顺序完成 2k2k 次移动,从 Pi1P_{i-1} 移动到 PiP_iii 从 1 到 2k2k)。

第二步:动态规划的状态设计

这个问题有明显的阶段性(完成第 ii 次移动),非常适合用动态规划来解决。DP 的关键在于如何定义状态,要把所有影响未来决策的信息都包含进去。

在每个阶段结束后,影响我们下一步决策的因素有:

  1. 我们当前的位置。
  2. 两个传送门的位置。
  3. 到目前为止的总花费。

如果把两个传送门的位置 (p1, p2) 都放进状态里,状态就会变成 dp[i][p1][p2],复杂度会是 O(KN2)O(K \cdot N^2),这本身没问题,但状态转移会非常复杂。

这里有一个关键的洞察点!因为我们只能在当前所在位置开启传送门,所以在我们辛辛苦苦走到一个目的地 v 之后,最明智的选择之一就是在 v 点开启一个传送门,方便未来的移动。

基于这个想法,我们可以简化状态定义: dp[i][j] 表示:完成了前 ii 次移动(也就是我们现在位于 PiP_i 点),并且其中一个传送门在 PiP_i 点,另一个传送门在 jj 点时,所需要的最小总路程。

初始状态: 在开始时(第 0 步),我们在顶点 1 (P0=1P_0=1) 。我们可以认为两个传送门都在顶点 1,这样不需要任何花费。所以,dp[0][1] = 0

第三步:激动人心的状态转移!

现在,我们来推导如何从状态 dp[i-1] 转移到 dp[i]。 假设我们要进行第 ii 次移动,从 u=Pi1u = P_{i-1} 前往 v=Piv = P_i。 在移动开始前,我们处于某个状态 dp[i-1][j],这意味着我们身在 uu 点,两个传送门分别在 uujj

我们的目标是到达 vv 点,并最终形成 (v, l) 的传送门配置,也就是计算出 dp[i][l] 的值。 为了从 (u, j) 配置转换到 (v, l) 配置,我们需要从 uu 出发,到达 vv,并且中途可能需要访问 ll 来开启新的传送门。在整个过程中,我们可以利用 (u, j) 传送门进行一次瞬移。

让我们来分析一下所有可能的策略,喵~

(u, j)(v, l) 的旅行策略

为了到达 v 并建立 (v, l) 传送门,我们必须从当前位置(u 或通过传送到达的 j)出发,走一条包含 lv 的路径。

  1. 策略一:纯走路,不瞬移 我们可以完全不使用 (u, j) 传送门,直接从 u 出发,经过 l 再到 v

    • 路径:ulvu \to l \to v
    • 花费:dist(u, l) + dist(l, v)
  2. 策略二:先瞬移,后走路 我们可以先从 u 瞬移到 j(花费为0),然后从 j 出发,经过 l 再到 v

    • 路径:uteleportjlvu \xrightarrow{\text{teleport}} j \to l \to v
    • 花费:dist(j, l) + dist(l, v)
  3. 策略三:先走一段,再瞬移,再走 这是一个非常巧妙的策略!

    • u 走第一段路到 l。花费 dist(u, l)
    • l 点,我们把之前在 u 点的传送门关掉,在 l 点开启一个新的。现在的传送门配置是 (l, j)
    • 我们身处 l,直接瞬移到 j
    • j 走第二段路到 v。花费 dist(j, v)
    • v 点,我们把 j 点的传送门关掉,在 v 点开启。最终传送门配置为 (l, v)
    • 总花费:dist(u, l) + dist(j, v)

所以,从 (u, {u, j}) 状态转移到 (v, {v, l}) 状态,所增加的行走距离就是以上三种策略的最小值。

状态转移方程

综合起来,dp[i][l] 的值可以通过遍历所有可能的上一个状态 dp[i-1][j] 来更新:

dp[i][l]=minj=1N(dp[i1][j]+cost(u,v,j,l))dp[i][l] = \min_{j=1 \dots N} \left( dp[i-1][j] + \text{cost}(u,v,j,l) \right)

其中,u=Pi1,v=Piu=P_{i-1}, v=P_i,转移花费为: cost(u,v,j,l)=min{dist(u,l)+dist(l,v)// 策略1dist(j,l)+dist(l,v)// 策略2dist(u,l)+dist(j,v)// 策略3\text{cost}(u,v,j,l) = \min \begin{cases} \text{dist}(u, l) + \text{dist}(l, v) & \text{// 策略1} \\ \text{dist}(j, l) + \text{dist}(l, v) & \text{// 策略2} \\ \text{dist}(u, l) + \text{dist}(j, v) & \text{// 策略3} \end{cases}

一个特殊的简化情况

上面的推导覆盖了将传送门从 j 改变l 的情况。那如果不改变呢?也就是从 (u, {u, j}) 转移到 (v, {v, j})。 这时 l=j,带入上面的花费公式: cost(u,v,j,j)=min{dist(u,j)+dist(j,v)dist(j,j)+dist(j,v)=dist(j,v)dist(u,j)+dist(j,v)=dist(j,v)\text{cost}(u,v,j,j) = \min \begin{cases} \text{dist}(u, j) + \text{dist}(j, v) \\ \text{dist}(j, j) + \text{dist}(j, v) = \text{dist}(j, v) \\ \text{dist}(u, j) + \text{dist}(j, v) \end{cases} = \text{dist}(j, v) 这对应了先瞬移 u -> j,再走路 j -> v 的策略。

但是,我们还有一种更直接的方式去 v:直接从 u 走过去!花费是 dist(u, v)。 所以,从 (u, {u, j})(v, {v, j}) 的花费应该是 min(dist(u, v), dist(j, v))

为了让逻辑更清晰,我们可以把状态转移分成两步:

  1. 保持传送门:对于每个 j,计算从 dp[i-1][j] 转移到 dp[i][j] 的成本。
  2. 改变传送门:对于每个 jl,计算从 dp[i-1][j] 转移到 dp[i][l] 的成本。

实际上,min(dist(u, v)) 这个情况可以被 dist(u,l)+dist(l,v) 这个策略家族所覆盖。如果我们考虑从 dp[i-1][u] 这个状态转移(即上一步的传送门在 uu),那么转移到 dp[i][l] 的花费中就会包含 dist(u,l) + dist(l,v) 这一项。因此,只要我们完整地执行三重循环的DP,就能覆盖所有情况!

最终答案

完成了所有 2k2k 次移动后,我们最终的位置在 P2kP_{2k}。此时的最小总花费就是 dp[2k] 这一行的所有值中的最小值,即 minj=1Ndp[2k][j]\min_{j=1 \dots N} dp[2k][j]

好啦,思路已经很清晰了,让我们把它变成漂亮的代码吧!喵~

代码实现

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

using namespace std;

const long long INF = 1e18; // 使用一个足够大的数表示无穷大

int main() {
    // 加速输入输出,喵~
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m, k;
    cin >> n >> m >> k;

    // 1. 预处理所有点对之间的最短路
    vector<vector<long long>> dist(n + 1, vector<long long>(n + 1, INF));

    for (int i = 1; i <= n; ++i) {
        dist[i][i] = 0;
    }

    for (int i = 0; i < m; ++i) {
        int u, v;
        long long w;
        cin >> u >> v >> w;
        // 处理重边,取最短的
        dist[u][v] = min(dist[u][v], w);
        dist[v][u] = min(dist[v][u], w);
    }

    // Floyd-Warshall 算法
    for (int p = 1; p <= n; ++p) {
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (dist[i][p] != INF && dist[p][j] != INF) {
                    dist[i][j] = min(dist[i][j], dist[i][p] + dist[p][j]);
                }
            }
        }
    }

    // 2. 构造任务路径序列 P
    vector<int> p(2 * k + 1);
    p[0] = 1; // 起点
    for (int i = 1; i <= k; ++i) {
        cin >> p[2 * i - 1] >> p[2 * i];
    }

    // 3. 动态规划
    // dp[i][j]: 完成第i次移动(到达p[i])后, 一个传送门在p[i], 另一个在j的最小花费
    vector<vector<long long>> dp(2 * k + 1, vector<long long>(n + 1, INF));

    // 初始状态:第0步,在起点1,传送门都在1,花费为0
    dp[0][1] = 0;

    for (int i = 1; i <= 2 * k; ++i) {
        int u = p[i - 1]; // 上一个位置
        int v = p[i];     // 当前目标位置

        // 优化:先计算出 min_{j} (dp[i-1][j] + cost_part)
        // 这样可以把 O(K*N^3) 优化到 O(K*N^2)
        long long min_prev_cost1 = INF; // min(dp[i-1][j])
        long long min_prev_cost2 = INF; // min(dp[i-1][j] + dist(j, v))
        vector<long long> min_prev_cost3(n + 1, INF); // min(dp[i-1][j] + dist(j, l)) for each l

        for (int j = 1; j <= n; ++j) {
            if (dp[i - 1][j] != INF) {
                min_prev_cost1 = min(min_prev_cost1, dp[i - 1][j]);
                min_prev_cost2 = min(min_prev_cost2, dp[i - 1][j] + dist(j, v));
            }
        }

        // 另一个优化,对 min_prev_cost3 的计算
        // min_{j} (dp[i-1][j] + dist(j, l))
        // 对于每个 l,都需要 O(N) 计算,总共是 O(N^2)
        for (int l = 1; l <= n; ++l) {
            for (int j = 1; j <= n; ++j) {
                if (dp[i - 1][j] != INF) {
                    min_prev_cost3[l] = min(min_prev_cost3[l], dp[i - 1][j] + dist(j, l));
                }
            }
        }
        
        // 状态转移
        for (int l = 1; l <= n; ++l) { // 新的传送门位置 l
            long long cost1 = (min_prev_cost1 == INF || dist[u][l] == INF || dist[l][v] == INF) ? INF : (min_prev_cost1 + dist[u][l] + dist[l][v]);
            long long cost2 = (min_prev_cost3[l] == INF || dist[l][v] == INF) ? INF : (min_prev_cost3[l] + dist[l][v]);
            long long cost3 = (min_prev_cost2 == INF || dist[u][l] == INF) ? INF : (min_prev_cost2 + dist[u][l]);

            dp[i][l] = min({cost1, cost2, cost3});
        }
    }

    // 4. 寻找最终答案
    long long min_total_dist = INF;
    for (int j = 1; j <= n; ++j) {
        min_total_dist = min(min_total_dist, dp[2 * k][j]);
    }

    cout << min_total_dist << endl;

    return 0;
}

代码说明:上面的代码实现了一个优化过的DP。原始的三重循环 for i for l for j 的复杂度是 O(KN2)O(K \cdot N^2)。通过预计算 min_{j} 的部分,我们可以将内层的 j 循环提取出来,从而将总复杂度维持在 O(KN2)O(K \cdot N^2),避免了 O(KN3)O(K \cdot N^3) 的超时风险。不过,即使是朴素的 O(KN2)O(K \cdot N^2) 实现(即把 j 循环放在 l 循环内部)通常也能通过此题。为了教学目的,我展示了一个思路更清晰的、略微优化的版本。

复杂度分析

  • 时间复杂度: O(N3+KN2)O(N^3 + K \cdot N^2)

    • Floyd-Warshall 算法的复杂度是 O(N3)O(N^3)
    • 动态规划部分,有 2K2K 个阶段。在每个阶段,我们计算 NN 个新的状态 dp[i][l]。为了计算每个 dp[i][l],我们遍历了所有 NN 个之前的状态 dp[i-1][j]。所以是 O(KN2)O(K \cdot N^2)
    • 总时间复杂度由这两部分主导。
  • 空间复杂度: O(KN+N2)O(K \cdot N + N^2)

    • dist 邻接矩阵需要 O(N2)O(N^2) 的空间。
    • dp 表需要 O(KN)O(K \cdot N) 的空间。由于 dp[i] 只依赖于 dp[i-1],我们还可以使用滚动数组将空间优化到 O(N)O(N),不过在本题的数据范围下,O(KN)O(K \cdot N) 也是完全可以接受的。

知识点总结

这道题真是太棒了,融合了多种算法思想,让我我收获满满!

  1. 图论建模: 将实际问题抽象成图论模型是解决问题的第一步。
  2. All-Pairs Shortest Path (全源最短路): 当需要在图中频繁查询任意两点间的最短路时,Floyd-Warshall 算法是一个非常有效的工具,尤其是在点数不多的情况下。
  3. 动态规划 (DP):
    • 状态定义: 如何抓住问题的核心,定义出既能包含所有必要信息又不过于复杂的状态,是DP的艺术所在。本题中“一个传送门在当前位置”的假设是关键。
    • 状态转移: 仔细分析所有可能的操作和策略,确保状态转移方程不重不漏。本题中的三种旅行策略就是一个很好的例子。
  4. 问题分解: 将一个复杂的问题(完成所有任务)分解成一系列小步骤(完成每次移动),然后通过DP将这些步骤的最优解组合起来,是解决复杂问题的常用思路。

希望这篇题解能帮助你理解这道有趣的题目,喵~ 如果还有不明白的地方,随时可以再来问我哦!