[NOIP 2017 提高组] 逛公园

GitHub跳转原题关系图返回列表

反向图Dijkstra预处理最短路,将路径长度转化为相对最短路的额外偏差量,结合记忆化搜索与递归栈判0环。

OJ: luogu

题目 ID: P3953

难度:提高+/省选-

标签:最短路Dijkstra动态规划记忆化搜索

创建: 2026-10-23 23:05

更新: 2026-10-26 14:33

形式化题目

给定一张包含 NN 个顶点、MM 条有向边的图,边权均为非负整数(可能包含 00 权边,但无自环与重边)。

设从点 11 到点 NN 的最短路径长度为 dd。需要计算: 从点 11 出发到达点 NN 且总路径长度不超过 d+Kd + K 的路径条数,结果对给定的整数 PP 取模。

若存在无穷多条满足条件的路径,则输出 −1-1。

暴力解法

思路

最朴素的方法是从起点 11 开始进行深度优先遍历(DFS),沿着出边不断累加路径权值 cur_lencur\_len。

  • 当 cur_len>d+Kcur\_len > d + K 时回溯;
  • 当搜索到达终点 NN 时,将答案计数加 11;
  • 针对可能存在的 00 权边,如果在当前搜索路径上多次访问同一个点且权值没有增加,说明进入了由 00 权边构成的环;若从该环出发能到达终点,则判定存在无穷多条合法路线并报告 −1-1。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-23 23:38
 * update_at: 2026-10-23 23:38
 */
// brute.cpp:小数据暴力解(带严格状态环检测的 DFS,用于对拍验证,仅适用于极小图)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 100;
const int MAXM = 200;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;

int head[MAXN], to_node[MAXM], nxt_edge[MAXM], edge_weight[MAXM], edge_cnt;
ll dis[MAXN];
bool vis_dijk[MAXN];
ll n, m, k, p;
ll ans_cnt;
bool infinite_paths;
bool in_stack_node[MAXN][500]; // 记录 (u, cur_len) 是否在搜索栈中

void add_edge(int u, int v, int w) {
    edge_cnt++;
    to_node[edge_cnt] = v;
    edge_weight[edge_cnt] = w;
    nxt_edge[edge_cnt] = head[u];
    head[u] = edge_cnt;
}

// 堆优化 Dijkstra 求 1 到各点的最短路
struct Node {
    int id;
    ll d;
    bool operator>(const Node &other) const { return d > other.d; }
};

void dijkstra() {
    for (int i = 1; i <= n; i++) {
        dis[i] = INF;
        vis_dijk[i] = false;
    }
    priority_queue<Node, vector<Node>, greater<Node>> pq;
    dis[1] = 0;
    pq.push({1, 0});

    while (!pq.empty()) {
        Node top = pq.top();
        pq.pop();
        int u = top.id;
        if (vis_dijk[u]) continue;
        vis_dijk[u] = true;

        for (int e = head[u]; e; e = nxt_edge[e]) {
            int v = to_node[e];
            int w = edge_weight[e];
            if (dis[v] > dis[u] + w) {
                dis[v] = dis[u] + w;
                pq.push({v, dis[v]});
            }
        }
    }
}

// 检查从 u 能否走到达 n 且距离 <= dis[n] + k
bool can_reach(int start_u, ll cur_dist) {
    queue<pair<int, ll>> q;
    q.push({start_u, cur_dist});
    bool visited[MAXN];
    for (int i = 1; i <= n; i++) visited[i] = false;
    visited[start_u] = true;
    while (!q.empty()) {
        auto [u, d] = q.front();
        q.pop();
        if (u == n) return true;
        for (int e = head[u]; e; e = nxt_edge[e]) {
            int v = to_node[e];
            int w = edge_weight[e];
            if (!visited[v] && d + w <= dis[n] + k) {
                visited[v] = true;
                q.push({v, d + w});
            }
        }
    }
    return false;
}

// 暴力 DFS 搜索所有从 1 出发、长度不超过 dis[n] + k 的路径
void dfs(int u, ll cur_len) {
    if (infinite_paths) return;
    if (cur_len > dis[n] + k) return;

    if (in_stack_node[u][cur_len]) {
        // 在同样累计长度下再次到达同一个点 u,说明走了一个 0 环!
        // 且只要从 u 出发能够到达终点 n,就意味着有无穷多条合法路线
        if (can_reach(u, cur_len)) {
            infinite_paths = true;
        }
        return;
    }

    in_stack_node[u][cur_len] = true;

    if (u == n) {
        ans_cnt = (ans_cnt + 1) % p;
    }

    for (int e = head[u]; e; e = nxt_edge[e]) {
        int v = to_node[e];
        int w = edge_weight[e];
        dfs(v, cur_len + w);
        if (infinite_paths) return;
    }

    in_stack_node[u][cur_len] = false;
}

void solve() {
    cin >> n >> m >> k >> p;
    edge_cnt = 0;
    for (int i = 1; i <= n; i++) head[i] = 0;

    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        add_edge(u, v, w);
    }

    dijkstra();
    if (dis[n] == INF) {
        cout << 0 << "\n";
        return;
    }

    ans_cnt = 0;
    infinite_paths = false;
    memset(in_stack_node, 0, sizeof(in_stack_node));

    dfs(1, 0);

    if (infinite_paths) {
        cout << -1 << "\n";
    } else {
        cout << ans_cnt % p << "\n";
    }
}

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

    int t;
    if (cin >> t) {
        while (t--) {
            solve();
        }
    }
    return 0;
}

复杂度

在图结构稠密或环较多时,搜索树的节点规模呈指数级爆炸,最坏时间复杂度为 O(Md+K)O(M^{d + K}),仅适用于极小规模图(如 N≤6,M≤12N \le 6, M \le 12)的调试与对拍。

瓶颈

暴力的核心瓶颈在于:

  1. 状态重复计算:同一对「当前所在点」与「已走距离」会被大量不同的前缀路径多次遍历;
  2. 状态值域过大:路径的绝对长度可达 N×max⁡(ci)≈108N \times \max(c_i) \approx 10^8,直接以实际距离作为状态数组下标在空间与时间上均不可行。

正解

思路

1. 状态降维:以「相对最短路的偏差量」作为状态

注意到题目给出了强约束 K≤50K \le 50,虽然路径的总长度很大,但一条合法路径相比于理论最短路最多只能多走 KK 的距离。

设各点 uu 到终点 NN 的最短距离为 dis[u]dis[u](可以通过在反向图上以点 NN 为源点运行堆优化 Dijkstra 算法求得)。 考虑一条从 uu 指向 vv、权值为 ww 的有向边 (u,v,w)(u, v, w):

  • 从 uu 沿该边走到 vv 再走到终点 NN,其理论能达到的最小总花费为 w+dis[v]w + dis[v];
  • 而从 uu 到 NN 本身的最短路为 dis[u]dis[u];
  • 由最短路的三角不等式性质,显然有 dis[u]≤dis[v]+wdis[u] \le dis[v] + w,即:
    Δ=dis[v]+w−dis[u]≥0\Delta = dis[v] + w - dis[u] \ge 0

这里的 Δ\Delta 即为经过边 (u,v)(u, v) 相比于走最短路多花出的额外代价(偏差量)。 既然整条路径从 11 到 NN 最多多走 KK 的距离,那么整条路径上经过的所有边的 Δ\Delta 之和必须不超过 KK!

2. 状态定义与转移方程

定义状态: dp[u][j]dp[u][j] 表示从点 uu 出发走到终点 NN,且后续路径相对于 dis[u]dis[u] 恰好多消耗了 jj 的额外偏差的合法方案数。

  • 初始基准:当 u=Nu = N 且 j=0j = 0 时,自身到达终点且无偏差,提供 11 种基础方案。
  • 状态转移: 遍历点 uu 的所有正向出边 (u,v,w)(u, v, w),计算 Δ=dis[v]+w−dis[u]\Delta = dis[v] + w - dis[u]。 若 j−Δ≥0j - \Delta \ge 0,则说明转移可行:
    dp[u][j]=∑(u,v,w)dp[v][j−Δ](modP)dp[u][j] = \sum_{(u, v, w)} dp[v][j - \Delta] \pmod P
  • 最终答案: 从起点 11 出发,枚举总额外花费 j∈[0,K]j \in [0, K],累加所有 dp[1][j](modP)dp[1][j] \pmod P。

下图把这条转移式拆成来源, 说明 dp[u][j]dp[u][j] 的每个候选值取自哪条边、以及第二维如何被 Δ\Delta 扣减:

flowchart LR
  S["状态 dp[u][j]"] --> E1["边 u 到 v1, 权 w1, 偏差 Δ1"]
  S --> E2["边 u 到 v2, 权 w2, 偏差 Δ2"]
  E1 --> D1["读取 dp[v1][j - Δ1], 需要 j 不小于 Δ1"]
  E2 --> D2["读取 dp[v2][j - Δ2], 需要 j 不小于 Δ2"]
  D1 --> SUM["求和得到 dp[u][j]"]
  D2 --> SUM
  D2 -.->|"j 小于 Δ 时越界"| DROP["该来源整项丢弃"]

图中两条出边各自把 jj 减去自己的 Δ\Delta 后再去读后继状态, 所以 Δ\Delta 越大, 同一个 jj 能接续的后继表项就越靠左; 虚线那一路是越界情形, 它对应转移式里的条件 j−Δ≥0j - \Delta \ge 0, 不满足就不能计入求和. 图中也直接暴露了 Δ\Delta 为 00 的特殊之处: 此时读取的是 dp[v][j]dp[v][j] 本身, 第二维一个都没减少.

下面用一个 44 个点的小图把这张二维表逐格算一遍. 取终点 N=4N = 4, 预算 K=3K = 3, 边集为 1→2 (w=1)1 \to 2\ (w = 1)、2→4 (w=1)2 \to 4\ (w = 1)、1→3 (w=3)1 \to 3\ (w = 3)、3→4 (w=1)3 \to 4\ (w = 1)、2→3 (w=0)2 \to 3\ (w = 0). 先在反向图上以点 44 为源跑 Dijkstra, 得到 dis[1]=2, dis[2]=1, dis[3]=1, dis[4]=0dis[1] = 2,\ dis[2] = 1,\ dis[3] = 1,\ dis[4] = 0, 再逐条边算 Δ\Delta:

边 (u→v)(u \to v) ww dis[v]+w−dis[u]dis[v] + w - dis[u] Δ\Delta 含义
1→21 \to 2 11 1+1−21 + 1 - 2 00 与最短路等长
2→42 \to 4 11 0+1−10 + 1 - 1 00 与最短路等长
2→32 \to 3 00 1+0−11 + 0 - 1 00 与最短路等长
3→43 \to 4 11 0+1−10 + 1 - 1 00 与最短路等长
1→31 \to 3 33 1+3−21 + 3 - 2 22 比最短路多花 22

四条 Δ=0\Delta = 0 的边共同构成本图的两条最短路, 只有 1→31 \to 3 是"绕远"的边. 下表每行是一个出发点 uu, 每列是剩余偏差量 j=0,1,2,3j = 0, 1, 2, 3, 单元格就是 dp[u][j]dp[u][j]. 取 PP 远大于这些数值 (例如 P=109+7P = 10^9 + 7) 时表里就是方案数的原值, 不必取模:

uu j=0j = 0 j=1j = 1 j=2j = 2 j=3j = 3
11 22 00 11 00
22 22 00 00 00
33 11 00 00 00
44 11 00 00 00

读表时先看 u=1u = 1 那一行: dp[1][0]=2dp[1][0] = 2 对应 1→2→41 \to 2 \to 4 与 1→2→3→41 \to 2 \to 3 \to 4 两条一个偏差都没花的走法, dp[1][2]=1dp[1][2] = 1 对应吃满 Δ=2\Delta = 2 的 1→3→41 \to 3 \to 4, 而 j=1j = 1 与 j=3j = 3 整列为 00 是因为本图各边的 Δ\Delta 只有 00 与 22, 凑不出奇数偏差, 整行相加得 2+0+1+0=32 + 0 + 1 + 0 = 3.

Δ\Delta 恒 ≥0\ge 0 由定义直接给出: dis[u]dis[u] 已是"从 uu 出发的所有走法"中最短的长度, 而 w+dis[v]w + dis[v] 只是其中"先走这条边再走最优后续"这一条具体走法的长度, 具体走法不会比最短的更短, 所以这一维记录的是"多花了多少"而不是"少花了多少", 它在每一步转移里只减不增, 这正是 jj 能被 KK 截断、状态总数只有 N×(K+1)N \times (K + 1) 的原因. Δ=0\Delta = 0 的边危险则是因为它读取的正是 dp[v][j]dp[v][j] 本身, jj 一点不减: 若干条这样的边首尾相接成环时, 就能停在同一层 jj 上无限绕圈, 每绕一圈都是新路线, 方案数变成无穷. 本例的 2→32 \to 3 就是一条 Δ=0\Delta = 0 的边, 只因补不出回路才无害; 若再加一条 3→2 (w=0)3 \to 2\ (w = 0) (它的 Δ=1+0−1=0\Delta = 1 + 0 - 1 = 0), 环就成形了, 且点 11 可达它、它可达点 44, 答案立刻变成 −1-1, 这正是记忆化搜索必须保留 in_stack 的原因.

3. 记忆化搜索与判断无穷解(0 权环)

因为转移存在拓扑关系(第二维 jj 在 Δ>0\Delta > 0 时严格减少;在 Δ=0\Delta = 0 时 jj 保持不变),采用记忆化搜索实现最为自然直观。

如何判断是否存在无穷多条路径并输出 −1-1? 当且仅当存在一个由 Δ=0\Delta = 0 的边构成的环,且该环能从起点 11 走到、并能顺利走到终点 NN 时,我们可以在该环上无限绕圈而偏差量不增,从而产生无穷多条合法路线。 在记忆化搜索中,我们维护一个布尔数组 in_stack[u][j] 记录当前状态 (u,j)(u, j) 是否正处于当前的递归调用栈中:

  • 若进入 dfs(u, j) 时发现 in_stack[u][j] == true,说明在相同的额外偏差量 jj 下回到了自身(走了一圈 Δ=0\Delta = 0 的回路);
  • 此时立即标记发现 00 环,整道题直接输出 −1-1。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-23 23:38
 * update_at: 2026-10-23 23:38
 */
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 100005;
const int MAXM = 200005;
const int MAXK = 55;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;

// 正向图与反向图的链式前向星
int head_f[MAXN], to_f[MAXM], nxt_f[MAXM], weight_f[MAXM], edge_cnt_f;
int head_b[MAXN], to_b[MAXM], nxt_b[MAXM], weight_b[MAXM], edge_cnt_b;

// 最短路相关
ll dis_from_1[MAXN]; // 正向图从 1 出发的最短距离
ll dis_to_n[MAXN];   // 反向图从 n 出发的最短距离(即各点到 n 的最短距离)
bool vis_dijk[MAXN];

// 记忆化搜索与判环标记
ll dp[MAXN][MAXK];
bool in_stack[MAXN][MAXK]; // 正在搜索栈中,用于检测 0 环引起的死循环

int n, m, k;
ll p;
bool has_zero_cycle;

void add_edge_forward(int u, int v, int w) {
    edge_cnt_f++;
    to_f[edge_cnt_f] = v;
    weight_f[edge_cnt_f] = w;
    nxt_f[edge_cnt_f] = head_f[u];
    head_f[u] = edge_cnt_f;
}

void add_edge_backward(int u, int v, int w) {
    edge_cnt_b++;
    to_b[edge_cnt_b] = v;
    weight_b[edge_cnt_b] = w;
    nxt_b[edge_cnt_b] = head_b[u];
    head_b[u] = edge_cnt_b;
}

struct Node {
    int id;
    ll d;
    bool operator>(const Node &other) const {
        return d > other.d;
    }
};

// 堆优化 Dijkstra 求正向最短路
void dijkstra_from_1() {
    for (int i = 1; i <= n; i++) {
        dis_from_1[i] = INF;
        vis_dijk[i] = false;
    }
    priority_queue<Node, vector<Node>, greater<Node>> pq;
    dis_from_1[1] = 0;
    pq.push({1, 0});

    while (!pq.empty()) {
        Node top = pq.top();
        pq.pop();
        int u = top.id;
        if (vis_dijk[u]) continue;
        vis_dijk[u] = true;

        for (int e = head_f[u]; e; e = nxt_f[e]) {
            int v = to_f[e];
            int w = weight_f[e];
            if (dis_from_1[v] > dis_from_1[u] + w) {
                dis_from_1[v] = dis_from_1[u] + w;
                pq.push({v, dis_from_1[v]});
            }
        }
    }
}

// 堆优化 Dijkstra 求反向最短路
void dijkstra_to_n() {
    for (int i = 1; i <= n; i++) {
        dis_to_n[i] = INF;
        vis_dijk[i] = false;
    }
    priority_queue<Node, vector<Node>, greater<Node>> pq;
    dis_to_n[n] = 0;
    pq.push({n, 0});

    while (!pq.empty()) {
        Node top = pq.top();
        pq.pop();
        int u = top.id;
        if (vis_dijk[u]) continue;
        vis_dijk[u] = true;

        for (int e = head_b[u]; e; e = nxt_b[e]) {
            int v = to_b[e];
            int w = weight_b[e];
            if (dis_to_n[v] > dis_to_n[u] + w) {
                dis_to_n[v] = dis_to_n[u] + w;
                pq.push({v, dis_to_n[v]});
            }
        }
    }
}

// 记忆化搜索:dfs(u, j) 表示从 u 走向 n,额外比 dis_to_n[u] 多花 j 的合法路线数
ll dfs(int u, int j) {
    if (j < 0 || j > k) return 0;
    if (in_stack[u][j]) {
        has_zero_cycle = true;
        return 0;
    }
    if (dp[u][j] != -1) {
        return dp[u][j];
    }

    in_stack[u][j] = true;
    ll total = 0;
    if (u == n && j == 0) {
        total = 1;
    }

    for (int e = head_f[u]; e; e = nxt_f[e]) {
        int v = to_f[e];
        int w = weight_f[e];
        if (dis_to_n[v] == INF) continue; // 无法到达终点

        ll extra = dis_to_n[v] + w - dis_to_n[u];
        ll next_j = j - extra;
        if (next_j >= 0 && next_j <= k) {
            total = (total + dfs(v, next_j)) % p;
            if (has_zero_cycle) return 0;
        }
    }

    in_stack[u][j] = false;
    dp[u][j] = total;
    return total;
}

void solve() {
    cin >> n >> m >> k >> p;

    edge_cnt_f = 0;
    edge_cnt_b = 0;
    for (int i = 1; i <= n; i++) {
        head_f[i] = 0;
        head_b[i] = 0;
    }

    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        add_edge_forward(u, v, w);
        add_edge_backward(v, u, w);
    }

    dijkstra_from_1();
    dijkstra_to_n();

    // 无法从 1 到达 n
    if (dis_from_1[n] == INF) {
        cout << 0 << "\n";
        return;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= k; j++) {
            dp[i][j] = -1;
            in_stack[i][j] = false;
        }
    }

    has_zero_cycle = false;
    ll ans = 0;
    for (int j = 0; j <= k; j++) {
        ans = (ans + dfs(1, j)) % p;
        if (has_zero_cycle) {
            cout << -1 << "\n";
            return;
        }
    }

    cout << ans << "\n";
}

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

    int t;
    if (cin >> t) {
        while (t--) {
            solve();
        }
    }
    return 0;
}

复杂度

  • 时间复杂度:
    • 反向图 Dijkstra 预处理各点到 NN 的最短路:时间复杂度为 O(Mlog⁡N)O(M \log N);
    • 记忆化搜索:状态总数为 N×(K+1)N \times (K + 1)。由于每个状态只会被完整展开并记忆化一次,所有状态转移遍历的所有出边总次数为 ∑udeg(u)×(K+1)=M×(K+1)\sum_u deg(u) \times (K + 1) = M \times (K + 1)。
    • 单组数据总时间复杂度为 O(Mlog⁡N+(N+M)K)O(M \log N + (N + M) K)。对于 N≤105,M≤2×105,K≤50N \le 10^5, M \le 2 \times 10^5, K \le 50,总运算量在 10710^7 级别,运行耗时约 0.2∼0.3s0.2 \sim 0.3\text{s},能充分在 2.0s2.0\text{s} 限制内处理全部数据。
  • 空间复杂度:
    • 链式前向星(正向与反向):O(N+M)O(N + M);
    • 最短路数组与堆:O(N)O(N);
    • DP 记忆化与递归栈标记数组:O(NK)O(NK),占用内存约 45MB45\text{MB},完全满足空间限制要求。

总结

本题的关键在于将无法直接用于 DP 的绝对距离转化为相对最短路的额外增量。利用 K≤50K \le 50 的极小范围,将原本难以表示的路径计数问题化为两维状态 (u,j)(u, j)。通过反向图 Dijkstra 算出基准距离,并在记忆化搜索中借助搜索栈精准拦截 00 权环,实现了思路简洁、结构严谨的最优解法。