Harris
发布于 2026-03-19 / 11 阅读
0
0

NOI 2020 美食家题解:拆点 + Max-Plus 矩阵快速幂 + 倍增优化

NOI 2020 题解:美食家 (Delicacy)

核心考点:动态规划、拆点技巧、广义矩阵快速幂 (Max-Plus Algebra)、倍增预处理优化。

1. 题目大意简述

给出一个 n 个点 m 条边的有向图。每到达一个点 u 会获得 c_u 的愉悦值。走过每一条边需要花费 w \in [1,5] 天。总时长要求恰好为 T 天。初始(第 0 天)在点 1,并且已经获得了 c_1 的愉悦值。最后一天必须回到点 1。此外,有 k 个特殊的美食节:在第 t_i 天到达点 x_i,会额外获得 y_i 的愉悦值。求 T 天后恰好回到点 1 能获得的最大愉悦值。

数据范围n \leq 50, m \leq 500, T \leq 10^9, k \leq 200, w \in \{1,2,3,4,5\}

2. 状态转移与朴素 DP

如果不考虑 T 非常大,这是一个经典的图上 DP 模型:设 dp[t][u] 表示第 t 天走到点 u 的最大愉悦值。转移方程:

dp[t + w][v] = \max(dp[t + w][v], dp[t][u] + c_v)

加上美食节的限制:如果当前天数 t 和地点 u 恰好匹配某个美食节,额外加上 y_i 即可。

但注意到 T \leq 10^9,显然不能逐天推导,这提示我们需要用到 矩阵快速幂

3. 核心转化一:拆点(处理边权 ​w > 1

标准的图上矩阵快速幂只能处理边权(时间花费)为 1 的情况。但题目中 w \in [1,5]。我们可以利用经典技巧:拆点。把每个点 u 拆分成 5 个状态:(u, 0), (u, 1), \dots, (u, 4)

对于每个点 u,连边 (u, j) \to (u, j-1)(其中 j = 1,2,3,4)。这些边表示“在路上经过了一天”。对于原图的一条边 u \to v,权值为 w,我们在新图上连边:

(u, 0) \to (v, w-1) \quad \text{权值为 } c_v

这样,新图共有 5n 个节点(最多 250 个),并且每条新边的花费时间均为 1 天

4. 核心转化二:广义矩阵乘法 (Max-Plus 代数)

在普通的矩阵乘法中,(AB)_{ij} = \sum_k A_{ik} B_{kj}。在图论求最长路的 DP 中,我们可以重新定义矩阵乘法为:

(A \otimes B)_{ij} = \max_k (A_{ik} + B_{kj})

这种定义满足结合律,因此依然可以使用 快速幂。我们构造一个 S \times S 的转移矩阵 MS = 5n)。M_{ij} 表示从新图的节点 i 走一步(1 天)到节点 j 的最大愉悦值增量。若不可达则设为 -\infty

5. 核心转化三:处理美食节与“向量乘法优化”(满分关键!)

如果没有美食节,答案就是初始向量 V 乘上 M^T 后的结果。有 k 个美食节,我们可以把时间轴分为 k+1 段。将事件按发生时间 t_i 从小到大排序。假设当前状态向量为 V,时间处于 cur\_time,下一个事件在 t_i 发生:

  1. 计算步数 ​dt = t_i - cur\_time
  2. 进行矩阵快速幂:​V \leftarrow V \cdot M^{dt}
  3. 把点 ​x_i 的对应状态值加上 ​y_i​V[(x_i, 0)] \mathrel{+}= y_i

时间复杂度陷阱:如果是普通的实现,每次处理一个事件需要做一次矩阵快速幂。矩阵大小 S = 250k 次矩阵快速幂复杂度为 O(k \cdot S^3 \log T)。这绝对会 TLE!

满分优化:倍增预处理(仅计算向量 × 矩阵) 注意到矩阵乘矩阵是 O(S^3),但向量乘矩阵是 O(S^2)!我们可以先花 O(S^3 \log T) 的时间预处理出转移矩阵 M 的所有 2^p 次方,即 base[p] = M^{2^p}。在处理每段事件间隔 dt 时,把 dt 二进制拆分,用当前的状态向量去依次乘以预处理好的矩阵:

由于每次都是向量乘矩阵,单次跨越的时间复杂度降为了 O(S^2 \log T)。总复杂度:预处理 O(S^3 \log T) + 事件跳跃 O(k S^2 \log T),可以轻松通过所有测试点!

6. C++ 核心代码实现

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 4e18; // 注意防溢出,用正数 INF 配合负号

int n, m, T, k;
int S; // 矩阵大小 5n

// 获取拆点后的 ID
// 点下标从 1 开始,附加状态 c 范围 [0, 4]
int get_id(int u, int c) {
    return (u - 1) + c * n + 1;
}

struct Matrix {
    ll mat[255][255];
    Matrix() {
        for(int i=1; i<=S; i++)
            for(int j=1; j<=S; j++)
                mat[i][j] = -INF;
    }
} base[35]; // base[p] 存 M^{2^p}

// 矩阵乘矩阵 (O(S^3))
Matrix mul(Matrix A, Matrix B) {
    Matrix res;
    for(int k_idx=1; k_idx<=S; k_idx++) {
        for(int i=1; i<=S; i++) {
            if(A.mat[i][k_idx] == -INF) continue;
            for(int j=1; j<=S; j++) {
                if(B.mat[k_idx][j] == -INF) continue;
                res.mat[i][j] = max(res.mat[i][j], A.mat[i][k_idx] + B.mat[k_idx][j]);
            }
        }
    }
    return res;
}

struct Vector {
    ll v[255];
    Vector() {
        for(int i=1; i<=S; i++) v[i] = -INF;
    }
};

// 向量乘矩阵 (O(S^2)) 满分关键!
Vector mul_vec(Vector A, Matrix B) {
    Vector res;
    for(int j=1; j<=S; j++) {
        for(int i=1; i<=S; i++) {
            if(A.v[i] != -INF && B.mat[i][j] != -INF) {
                res.v[j] = max(res.v[j], A.v[i] + B.mat[i][j]);
            }
        }
    }
    return res;
}

struct Event {
    int t, x;
    ll y;
    bool operator<(const Event& rhs) const {
        return t < rhs.t;
    }
} ev[205];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m >> T >> k;
    S = 5 * n;
  
    vector<ll> c(n + 1);
    for(int i=1; i<=n; i++) cin >> c[i];

    // 初始化转移矩阵
    for(int i=1; i<=n; i++) {
        for(int j=1; j<=4; j++) {
            // (i, j) -> (i, j-1) 权值为 0
            base[0].mat[get_id(i, j)][get_id(i, j-1)] = 0;
        }
    }

    for(int i=1; i<=m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        // (u, 0) -> (v, w-1) 权值为 c[v]
        base[0].mat[get_id(u, 0)][get_id(v, w-1)] = c[v];
    }

    // 预处理倍增矩阵
    for(int p=1; p<=30; p++) {
        base[p] = mul(base[p-1], base[p-1]);
    }

    for(int i=1; i<=k; i++) {
        cin >> ev[i].t >> ev[i].x >> ev[i].y;
    }
    sort(ev + 1, ev + 1 + k);

    // 初始状态
    Vector V;
    V.v[get_id(1, 0)] = c[1];
  
    int cur_time = 0;
    for(int i=1; i<=k; i++) {
        int dt = ev[i].t - cur_time;
        if (dt < 0) continue; // 忽略无效事件
      
        // 用向量乘法跳跃 dt 步
        for(int p=30; p>=0; p--) {
            if((dt >> p) & 1) {
                V = mul_vec(V, base[p]);
            }
        }
      
        cur_time = ev[i].t;
        // 加上美食节的奖励
        if (V.v[get_id(ev[i].x, 0)] != -INF) {
            V.v[get_id(ev[i].x, 0)] += ev[i].y;
        }
    }

    // 处理最后一个事件到 T 的剩余时间
    if (cur_time < T) {
        int dt = T - cur_time;
        for(int p=30; p>=0; p--) {
            if((dt >> p) & 1) {
                V = mul_vec(V, base[p]);
            }
        }
    }

    if(V.v[get_id(1, 0)] < 0) {
        cout << -1 << "\n";
    } else {
        cout << V.v[get_id(1, 0)] << "\n";
    }

    return 0;
}

7. 总结

  1. 遇到“图上行走找最大收益,并且 ​T 极大”时,条件反射写出 Max-Plus 代数下的矩阵乘法。
  2. 遇到“边权为极小常数但不是 1”的情况,利用拆点技巧把图的规模乘以边权上限。
  3. 重点:在需要多次查询/跳跃不同步数的矩阵乘法时,预处理矩阵的 ​2^p 的幂次,使用向量 × 矩阵能实现降维打击,是节省时间的黄金法则!

评论