[NOIP 2009 普及组] 道路游戏

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

对角线前缀和 O(1) 查段收益,交换 max 后每条对角线单调队列滑动窗口,O(nm) 决策优化 DP。

OJ: luogu

题目 ID: P1070

难度:提高

标签:dp单调队列决策优化

创建: 2026-10-23 23:20

更新: 2026-10-23 23:35

形式化题目

环形马路有 nn 个机器人工厂(顺时针编号 1∼n1 \sim n,第 ii 段马路连接工厂 ii 与 i+1i+1,第 nn 段连接 nn 与 11)。第 jj 个单位时间内,第 ii 段马路上有 c[i][j]c[i][j] 枚金币。

机器人从工厂 ii 在时间 ss 买入、走 kk 步(1⩽k⩽p1 \leqslant k \leqslant p),第 ll 步过马路 ((i+l−1) mod n)((i+l-1) \bmod n),收集该段在第 s+ls+l 个单位时间的金币。每段机器人走完后立即在任意工厂买入下一个机器人(瞬时,不消耗时间),环上同时只能有一个机器人。游戏恰进行 mm 个单位时间,各段步数之和恰为 mm。

给定 c[i][j]c[i][j] 与购买花费 cost[i]cost[i],求(收集金币总和 − 购买花费总和)的最大值。

样例

输入:

text
2 3 2
1 2 3
2 3 4
1 2

输出:

text
5

样例解释:n=2,m=3,p=2n = 2, m = 3, p = 2。最优方案:在时间 00 从工厂 11 买入走 22 步,收集 c[1][1]+c[2][2]=1+3=4c[1][1] + c[2][2] = 1 + 3 = 4,花费 cost[1]=1cost[1] = 1,净收益 33;在时间 22 从工厂 22 买入走 11 步,收集 c[2][3]=4c[2][3] = 4,花费 cost[2]=2cost[2] = 2,净收益 22。总计 3+2=53 + 2 = 5。

暴力解法

思路

把游戏看成「从时间 depdep 出发,先决定下一个机器人走几步、从哪个工厂出发」。用选择序列递归枚举所有分段方式与起点工厂组合,取最大值。

段间无位置衔接约束(新机器人可在任意工厂购买),所以不需要记录上一段结束时的工厂。每层递归选择「段长 L∈[1,p]L \in [1, p]」与「起点工厂 i∈[0,n)i \in [0, n)」,段收益用三重循环逐个 ll 累加。

代码

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:20
 * update_at: 2026-10-23 23:34
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
//
// 直接按题意模拟:游戏恰进行 m 个单位时间,各段步数之和恰为 m;
// 每段由一个机器人走完,段长 L ∈ [1, p],起点工厂 i ∈ [0, n) 任选。
// 机器人在时间 s 于工厂 i 买入、走 L 步,第 l 步过马路 (i+l-1) mod n,
// 收集 w[(i+l-1) mod n][s+l],段收益 = 收金总和 - cost[i]。
//
// 用选择序列递归枚举所有分段方式与起点工厂组合,取最大值。
//
// 为什么只适合小数据:分段方式有 2^m 量级,每段再乘 n 个起点工厂,
// n=8, m=8 时已经很慢;只用于 n, m <= 8 的对拍。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 12;
const int MAXM = 12;

int n, m, p;
ll cost[MAXN];        // cost[i]:工厂 i 的购买花费(0-based)
ll w[MAXN][MAXM];     // w[road][tm]:第 tm 个时间第 road 段马路的金币
ll best_answer;       // 全局最优解

// 从工厂 start 出发走 L 步,收集的金币总和
// 步 l 过马路 (start+l-1) mod n,收 w[...][s+l]
ll walk_gain(int start, int s, int L) {
    ll sum = 0;
    for (int l = 1; l <= L; l++) {
        int road = (start + l - 1) % n;
        sum += w[road][s + l];
    }
    return sum;
}

// 选择序列递归:dep 是当前时间(下一个机器人买入的时刻),已走完 dep 个时间单位
// 这一层在选择「下一个机器人走几步、从哪个工厂出发」
void dfs(int dep, ll cur) {
    if (dep == m) {
        if (cur > best_answer) best_answer = cur;
        return;
    }
    int maxL = min(p, m - dep);
    for (int L = 1; L <= maxL; L++) {
        for (int i = 0; i < n; i++) {
            ll g = walk_gain(i, dep, L) - cost[i];
            dfs(dep + L, cur + g);
        }
    }
}

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

    cin >> n >> m >> p;
    for (int road = 0; road < n; road++) {
        for (int tm = 1; tm <= m; tm++) {
            cin >> w[road][tm];
        }
    }
    for (int i = 0; i < n; i++) cin >> cost[i];

    best_answer = -(1LL << 60);
    dfs(0, 0);
    cout << best_answer << "\n";
    return 0;
}

复杂度

时间:分段方式有 2m2^m 量级,每段再乘 nn 个起点工厂,指数级。空间:O(m)O(m) 递归栈。

只适合 n,m⩽8n, m \leqslant 8 的小数据与对拍。

瓶颈

枚举所有分段方式与起点工厂,没有利用「段收益只依赖 (起点工厂, 开始时间, 长度)」这一结构性质,也没有用前缀和加速段收益查询。

DP + 对角前缀和

思路

观察:机器人从工厂 ii 在时间 ss 出发走 kk 步,第 ll 步过马路 (i+l−1) mod n(i+l-1) \bmod n、收第 s+ls+l 时间的金币。令 u≡(road−time)(modn)u \equiv (\mathrm{road} - \mathrm{time}) \pmod n,则每一步都落在同一条对角线 u=(i−1−s) mod nu = (i - 1 - s) \bmod n 上,收益沿对角线连续累加。

定义对角线前缀和 D[u][t]D[u][t] = 对角线 uu 上「时间 ⩽t\leqslant t」的金币前缀和,则段收益 =D[u][s+k]−D[u][s]= D[u][s+k] - D[u][s],O(1)O(1) 查询。

设 f[t]f[t] 表示前 tt 个时间单位全部走完的最大净收益(f[0]=0f[0] = 0):

f[t]=max⁡k=1min⁡(p,t)(f[t−k]+max⁡i=0n−1(D[u][t]−D[u][t−k]−cost[i]))f[t] = \max_{k=1}^{\min(p,t)} \Big( f[t-k] + \max_{i=0}^{n-1} \big( D[u][t] - D[u][t-k] - cost[i] \big) \Big)

其中 u=(i−1−(t−k)) mod nu = (i - 1 - (t-k)) \bmod n。

用对角前缀和消掉内层 ll 循环,但 max⁡i\max_i 与 max⁡k\max_k 仍是三重循环。

代码

不单独给出(与正解共享对角前缀和预处理,转移部分是正解的未优化版本)。

复杂度与瓶颈

时间:O(n⋅m⋅p)O(n \cdot m \cdot p),n=m=p=1000n = m = p = 1000 时 10910^9,不稳健。空间:O(n⋅m)O(n \cdot m)。

瓶颈:max⁡i\max_i 与 max⁡k\max_k 无法分离,对固定的 tt 与 kk,max⁡i\max_i 被反复重算。

正解

关键观察

交换 max⁡s\max_s 与 max⁡u\max_u(逐点对每个 tt 合法):

f[t]=max⁡u(D[u][t]+max⁡s∈[t−p, t−1]hu(s))f[t] = \max_{u} \Big( D[u][t] + \max_{s \in [t-p,\ t-1]} h_u(s) \Big)

其中 hu(s)=f[s]−D[u][s]−cost[(u+s+1) mod n]h_u(s) = f[s] - D[u][s] - cost[(u+s+1) \bmod n]。

核心:hu(s)h_u(s) 只依赖 (u,s)(u, s),与 tt 无关!窗口 [t−p,t−1][t-p, t-1] 随 tt 单调右移,对每个 uu 维护一个单调队列做滑动窗口最大值。

思路

  1. 预处理对角线前缀和 D[u][t]D[u][t],O(n⋅m)O(n \cdot m)。
  2. 逐 tt 求 f[t]f[t]:
    • 先把 s=t−1s = t-1 入队(f[t−1]f[t-1] 已知),计算 hu(t−1)h_u(t-1) 并维护单调队列。
    • 用当前队列的队首(窗口内最大 huh_u)更新 f[t]f[t]。
  3. 答案 =f[m]= f[m]。

入队顺序关键:先把 s=t−1s = t-1 入队,再算 f[t]f[t],这样 hu(t−1)h_u(t-1) 参与 f[t]f[t] 及之后的计算,而 hu(t)h_u(t) 要等下一轮才入队。

代码

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:20
 * update_at: 2026-10-23 23:34
 */
// main.cpp:P1070 道路游戏 正解(决策优化,O(n*m))。
//
// 状态定义:
//   f[t] = 前 t 个时间单位全部走完(各段步数之和恰为 t)的最大净收益,f[0] = 0。
//
// 关键观察(对角线不变量):
//   时间 s 在工厂 i 买入、走 k 步,第 l 步过马路 (i+l-1) mod n,
//   收集 w[(i+l-1) mod n][s+l]。令 u ≡ (road - time) (mod n),
//   则「马路下标 - 时间下标」≡ u 恒定,收益沿对角线 u 连续:
//       Σ_l w[...][s+l] = D[u][s+k] - D[u][s]
//   其中 D[u][t] = 对角线 u 上「时间 <= t」的金币前缀和。
//   反过来给定 (u, s) 时 i = (u + s + 1) mod n 唯一确定,所以购买花费可写成 cost[(u+s+1) mod n]。
//
// 转移:
//   f[t] = max_{s ∈ [t-p, t-1]} ( f[s] + max_u ( D[u][t] - D[u][s] - cost[(u+s+1) mod n] ) )
//        = max_u ( D[u][t] + max_{s ∈ [t-p, t-1]} h_u(s) )
//   其中 h_u(s) = f[s] - D[u][s] - cost[(u+s+1) mod n] 只依赖 (u, s)、与 t 无关。
//
//   ⚠ 交换 max 合法性:max_s max_u == max_u max_s 是逐点(对每个 t)成立的,
//     因此外层枚举 t、内层枚举 u;不能把 u 放外层,否则 h_u(s) 用到的 f[s]
//     可能还没算完(s < t 但 f[s] 还会被后面的 u' 更新)。
//
//   窗口 [t-p, t-1] 随 t 单调右移,对每条对角线 u 维护一个单调队列做
//   滑动窗口最大值。求完 f[t] 后再把 h_u(t) 入队(因为 h_u(t) 影响的是 t'>t)。
//   总复杂度 O(n*m)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 1005;         // 工厂 / 马路上限
const int MAXM = 1005;         // 时间上限
const int DIAG_SHIFT = MAXM;   // u 平移量,保证下标非负
const int MAXU = DIAG_SHIFT + MAXN + 5; // 对角线截距 u 平移后的数组上界

int n, m, p;
ll cost[MAXN];        // cost[i]:在第 i 个工厂购买机器人的花费(0-based,与时间无关)
ll w[MAXN][MAXM];     // w[road][tm]:第 tm 个单位时间第 road 段马路上的金币
ll diag[MAXU][MAXM];  // diag[u][t]:对角线 u 上「时间 <= t」的金币前缀和
ll f[MAXM];           // f[t]:前 t 个时间单位的最大净收益

// 对角线编号:u ≡ (road - time) (mod n),取代表元后平移 DIAG_SHIFT
inline int diag_id(int road, int tm) {
    int u = ((road - tm) % n + n) % n;
    return u + DIAG_SHIFT;
}

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

    cin >> n >> m >> p;
    for (int road = 0; road < n; road++) {
        for (int tm = 1; tm <= m; tm++) {
            cin >> w[road][tm];
        }
    }
    for (int i = 0; i < n; i++) cin >> cost[i];

    // 1) 对角线前缀和:diag[u][tm] = diag[u][tm-1] + 该时间落在对角线 u 上的金币
    //    每个时间 tm 恰好有 n 个马路各贡献 1 个金币,分别落在 n 条对角线上(互不重复)。
    for (int u = 0; u < MAXU; u++) diag[u][0] = 0;
    for (int tm = 1; tm <= m; tm++) {
        for (int u = 0; u < MAXU; u++) diag[u][tm] = diag[u][tm - 1];
        for (int road = 0; road < n; road++) {
            int u = diag_id(road, tm);
            diag[u][tm] += w[road][tm];
        }
    }

    // 2) DP:f[0] = 0;f[t] 初值取 -inf,表示「还没算出来」
    f[0] = 0;
    for (int t = 1; t <= m; t++) f[t] = -(1LL << 60);

    // 3) 逐 t 求 f[t];内层枚举对角线 u,外层枚举 t。
    //    对每个 u 一个单调队列,队列内容只增不减(下标单调递增,hval 递减)。
    //    ⚠ 顺序关键:先把 s = t-1 入队(f[t-1] 已知),再用当前队列算 f[t]。
    static int qidx[MAXN][MAXM]; // qidx[u][k]:对角线 u 的队列第 k 个候选的下标 s
    static int qhead[MAXN], qtail[MAXN]; // 队列头尾(左闭右开)
    static ll hval[MAXN][MAXM]; // hval[u][s]:对角线 u 在 s 处的候选值

    for (int t = 1; t <= m; t++) {
        // 3.1) 把 s = t-1 加入候选(f[t-1] 已知);t=1 时即 f[0]=0 的基线
        for (int u = 0; u < n; u++) {
            int s = t - 1;
            int i = (u + s + 1) % n; // i = (u + s + 1) mod n
            hval[u][s] = f[s] - diag[u + DIAG_SHIFT][s] - cost[i];
            while (qtail[u] > qhead[u] && hval[u][qidx[u][qtail[u] - 1]] <= hval[u][s]) qtail[u]--;
            qidx[u][qtail[u]] = s;
            qtail[u]++;
        }
        // 3.2) 用已知 f[s] (s < t) 更新 f[t]:对每个 u 求滑动窗口最大值,取全局最大
        for (int u = 0; u < n; u++) {
            while (qhead[u] < qtail[u] && qidx[u][qhead[u]] < t - p) qhead[u]++;
            if (qhead[u] < qtail[u]) {
                int s = qidx[u][qhead[u]];
                ll cand = diag[u + DIAG_SHIFT][t] + hval[u][s];
                if (cand > f[t]) f[t] = cand;
            }
        }
    }

    cout << f[m] << "\n";
    return 0;
}

复杂度

时间:O(n⋅m)O(n \cdot m),n=m=1000n = m = 1000 时约 10610^6,实测 29ms。空间:O(n⋅m)O(n \cdot m),实测 37.8MB。

总结

本题的关键是对角线不变量:机器人走的每一步都落在同一条「马路 − 时间」对角线上,收益沿对角线连续累加,用前缀和 O(1)O(1) 查询段收益。进一步观察到 hu(s)h_u(s) 与 tt 无关,交换 max⁡\max 后对每条对角线用单调队列做滑动窗口最大值,把 O(nmp)O(nmp) 降到 O(nm)O(nm)。

三层解法的关系:

  1. 暴力层:枚举分段方式与起点工厂,暴露「段与段之间只共享结束时间」的 DP 无后效性。
  2. DP + 对角前缀和:用前缀和消掉内层 ll 循环,但 max⁡i\max_i 与 max⁡k\max_k 仍是三重循环。
  3. 决策优化:交换 max⁡\max + 单调队列,把三重循环降到两重,O(nm)O(nm) 过全部数据。