Skip to content

命运的齿轮 - 题解

标签与难度

标签: 动态规划, 深度优先搜索(DFS), 数位DP思想, 混合基数, 贪心 难度: 2100

题目大意喵~

ฅ^•ﻌ•^ฅ 各位Master,大家好呀!今天我们来挑战瑶桑的“命运的齿轮”!

这道题是这样的:我们有 NN 个齿轮,它们像一个奇特的时钟一样联动。

  • ii 个齿轮有 CiC_i 个齿,每个齿都有一个攻击值。
  • 齿轮们的转动方式很特别:最快的 0 号齿轮每秒转一格。当 0 号齿轮转完一整圈(也就是 C0C_0 秒后),1 号齿轮才会“咔”地转一格。同样,当 1 号齿轮转完一圈(也就是 C0×C1C_0 \times C_1 秒后),2 号齿轮才会转一格,以此类推。
  • 在任意时刻 TT,总伤害是所有齿轮当前指针指向的齿的攻击值之和。

我们的任务是,对于 QQ 个独立的询问,每个询问会给出一只果酱的血量 HH 和它出现的时间段 [L,R][L, R]。我们需要找到一个最早的时刻 TT,满足 LTRL \le T \le R,并且该时刻的总伤害足以一击必杀果酱(即总伤害 H\ge H)。如果找不到这样的时刻,就说明这只果酱溜走了,我们要输出 -1 呢。

简单来说,就是要在指定时间区间内,找到满足伤害要求的最小时间点,喵~

解题思路分析

这道题看起来有点复杂,但别担心,让我带你一步步拆解它,呐!

1. 齿轮的秘密:混合基数系统

首先,我们来分析齿轮的转动规律。Master有没有觉得,这很像我们平时用的时间单位?“秒”满了进位成“分”,“分”满了进位成“时”。这里的齿轮也是一样!

  • 0 号齿轮的指针位置,在 TT 时刻就是 T(modC0)T \pmod{C_0}
  • 1 号齿轮每 C0C_0 秒转一次,所以它的指针位置是 T/C0(modC1)\lfloor T / C_0 \rfloor \pmod{C_1}
  • 2 号齿轮每 C0×C1C_0 \times C_1 秒转一次,指针位置是 T/(C0×C1)(modC2)\lfloor T / (C_0 \times C_1) \rfloor \pmod{C_2}

这其实就是一个**混合基数(Mixed Radix)**系统!任何一个时间点 TT 都可以被唯一地表示成一组“数位” (dN1,dN2,,d0)(d_{N-1}, d_{N-2}, \dots, d_0),其中 did_i 就是第 ii 个齿轮的指针位置。它们之间的关系是:

T=d0W0+d1W1++dN1WN1T = d_0 \cdot W_0 + d_1 \cdot W_1 + \dots + d_{N-1} \cdot W_{N-1}

这里的 WiW_i 是权重,或者说是“进位基数”:

  • W0=1W_0 = 1
  • W1=C0W_1 = C_0
  • W2=C0×C1W_2 = C_0 \times C_1
  • ...
  • Wi=k=0i1CkW_i = \prod_{k=0}^{i-1} C_k

TT 时刻的总伤害就是 i=0N1Ai,di\sum_{i=0}^{N-1} A_{i, d_i},其中 Ai,diA_{i, d_i} 是第 ii 个齿轮在 did_i 位置的伤害值。

2. 问题的转化:寻找最小的“魔法数字”

现在问题就清晰多啦!我们不再是漫无目的地在时间长河里寻找,而是在寻找一个最小的“数字” TT,这个数字需要满足:

  1. TLT \ge L
  2. TRT \le R
  3. TT 对应的“各位数位”伤害之和 H\ge H

直接从 LL 遍历到 RR 肯定会超时的说,因为 RR 太大了!我们需要更聪明的办法。

3. 从高位到低位,贪心搜索!

要找一个最小的、且不小于 LL 的数,一个非常经典的想法是从最高位(也就是最高阶的齿轮,这里是 N1N-1 号)开始,一位一位地确定它的“数位”(指针位置)。

这就像我们在字典里找第一个不小于 "cat" 的单词一样,我们会先看首字母,如果首字母比 'c' 大,比如 'd',那后面的字母就可以随便取最小的 'a', 'a' ... 就能构成一个满足条件的单词 "daa..."。如果首字母也是 'c',那我们就要看第二位,以此类推。

这个过程,最适合用深度优先搜索 (DFS) 来实现,并且带有一点数位DP的思想,喵~

我们的 DFS 函数可以设计成这样:dfs(gear_index, damage_needed, is_l_bound)

  • gear_index: 当前我们正在为第 gear_index 个齿轮决定指针位置。我们从 N1N-1 号齿轮开始,一直到 0 号。
  • damage_needed: 为了达到总伤害 HH,从当前齿轮到 0 号齿轮,我们还需要凑够多少伤害。
  • is_l_bound: 这是一个布尔值,非常关键!它告诉我们,到目前为止,我们为更高阶齿轮选择的指针位置是否和 LL 的“数位”完全一样。
    • 如果 is_l_boundtrue,那么当前齿轮的指针位置 d 至少要从 LL 对应的数位 l_digits[gear_index] 开始选,不能更小。
    • 如果 is_l_boundfalse,说明我们在更高位已经选择了比 LL 大的数位,所以现在“解除了限制”,当前齿轮的指针可以从 0 开始随便选啦!

4. 剪枝,让搜索跑得更快!

纯粹的搜索还是太慢,我们需要剪掉那些不可能成功的路径。

假设我们正在考虑第 gear_index 个齿轮,已经确定了它和更高阶齿轮的指针,累积了 current_damage 的伤害。我们还需要 H - current_damage 的伤害。这时,我们就要问:剩下的 gear_index-10 号齿轮,就算它们都发挥出最大威力,能凑够这么多伤害吗?

为了快速回答这个问题,我们可以预处理一个数组 max_damage_suffix[i],表示从 0 号到 ii 号齿轮所能提供的最大伤害总和。

max_damage_suffix[i]=k=0imaxj(Ak,j)\text{max\_damage\_suffix}[i] = \sum_{k=0}^{i} \max_{j}(A_{k,j})

在搜索到 gear_index 时,如果我们为它选择了指针 d,那么需要的剩余伤害是 damage_needed - A[gear_index][d]。如果这个值大于 max_damage_suffix[gear_index-1],那这条路就走不通了,可以直接剪枝,喵!

5. 最终的搜索逻辑

结合以上思路,我们的搜索过程如下:

  1. 预处理:

    • 计算每个齿轮的权重 WiW_i(注意可能会溢出 long long,需要设一个上限)。
    • 计算后缀最大伤害 max_damage_suffix
    • 把查询的下限 LL 转换成混合基数下的各位数位 l_digits
  2. DFS 搜索:

    • dfs(N-1, H, true) 开始。
    • dfs(idx, required_dmg, is_tight) 函数中:
      • 循环: 遍历当前齿轮 idx 所有可能的指针位置 dd 的下限由 is_tightl_digits[idx] 决定。
      • 剪枝: 检查 required_dmg - A[idx][d] 是否超过了后面齿轮能提供的最大伤害,如果超过就跳过。
      • 递归: 调用 dfs(idx-1, ...) 处理下一个齿轮。新的 is_tight 标志取决于当前 d 是否等于 l_digits[idx]
      • 贪心: 一旦我们选择了一个 d > l_digits[idx](此时 is_tight 变为 false),我们就已经保证了最终的时间 TT 会大于 LL。为了让 TT 最小,之后所有更低阶的齿轮都应该在满足伤害要求的前提下,选择能构成最小时间的指针组合。我们的搜索顺序(d 从小到大)和递归结构天然地保证了这一点。所以,只要在 is_tight 变为 false 后找到了第一个解,那就是当前分支下的最优解,可以直接返回,这是一个超棒的优化!
  3. 得到答案:

    • DFS会返回一个满足条件的最小时间 TT
    • 最后,我们检查这个 TT 是否超过了查询的上限 RR。如果超过了,就说明在 [L,R][L, R] 区间内无解。

这样一来,我们就能高效地找到答案啦!是不是感觉清晰多了呢?

代码实现

这是我根据上面的思路,精心重构的一份代码,希望能帮到Master!

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

using namespace std;

// 使用 long long 防止伤害和时间溢出
using ll = long long;

const ll INF = 2e18; // 一个足够大的数,代表无穷大或无效

int n, q;
vector<int> C; // C[i]: 第 i 个齿轮的齿数
vector<vector<ll>> A; // A[i][j]: 第 i 个齿轮第 j 个齿的攻击值
vector<ll> W; // W[i]: 第 i 个齿轮的权重(进位基数)
vector<ll> max_damage_suffix; // max_damage_suffix[i]: 0到i号齿轮能提供的最大伤害和

ll target_h, time_l, time_r;
vector<int> l_digits; // time_l 在混合基数下的表示

// 深度优先搜索函数
// gear_idx: 当前处理的齿轮编号 (从 n-1 到 0)
// damage_needed: 还需要凑多少伤害
// is_l_bound: 当前是否受L的数位限制
// 返回值: 从 gear_idx 到 0 号齿轮能构成的满足条件的最小时间增量
ll dfs(int gear_idx, ll damage_needed, bool is_l_bound) {
    // 确保伤害需求不为负
    if (damage_needed < 0) {
        damage_needed = 0;
    }

    // 基础情况:所有齿轮都已确定
    if (gear_idx < 0) {
        // 如果伤害还不够,说明此路不通
        return (damage_needed > 0) ? INF : 0;
    }

    // 剪枝:如果剩下所有齿轮的最大伤害都无法满足需求,此路不通
    if (max_damage_suffix[gear_idx] < damage_needed) {
        return INF;
    }

    ll min_time_contribution = INF;
    int start_digit = is_l_bound ? l_digits[gear_idx] : 0;

    // 遍历当前齿轮所有可能的指针位置
    for (int d = start_digit; d < C[gear_idx]; ++d) {
        // 递归求解子问题
        bool next_is_l_bound = is_l_bound && (d == start_digit);
        ll remaining_time = dfs(gear_idx - 1, damage_needed - A[gear_idx][d], next_is_l_bound);

        // 如果子问题有解
        if (remaining_time != INF) {
            // 计算当前选择构成的总时间
            // 检查 W[gear_idx] 是否有效,以及乘法是否会溢出
            if (W[gear_idx] != INF && (INF / W[gear_idx]) < d) {
                 min_time_contribution = INF;
            } else {
                 min_time_contribution = d * W[gear_idx] + remaining_time;
            }
            
            // 优化:一旦解除了L的限制(is_l_bound变为false),
            // 我们找到的第一个解就是最优解,因为我们是按d从小到大遍历的。
            // 直接返回,无需继续搜索。
            return min_time_contribution;
        }
    }

    return min_time_contribution;
}

void solve() {
    cin >> n >> q;

    C.resize(n);
    A.assign(n, vector<ll>());
    W.assign(n + 1, 0);
    max_damage_suffix.assign(n, 0);

    // 预处理权重 W
    W[0] = 1;
    for (int i = 0; i < n; ++i) {
        cin >> C[i];
        A[i].resize(C[i]);
        ll max_gear_damage = 0;
        for (int j = 0; j < C[i]; ++j) {
            cin >> A[i][j];
            max_gear_damage = max(max_gear_damage, A[i][j]);
        }

        // 预处理后缀最大伤害
        max_damage_suffix[i] = (i > 0 ? max_damage_suffix[i - 1] : 0) + max_gear_damage;

        // 计算下一个权重,处理溢出
        if (i + 1 < n) {
            if (W[i] == INF || (C[i] > 0 && INF / C[i] < W[i])) {
                W[i + 1] = INF;
            } else {
                W[i + 1] = W[i] * C[i];
            }
        }
    }

    for (int i = 0; i < q; ++i) {
        cin >> target_h >> time_l >> time_r;

        // 将 time_l 转换为混合基数表示
        l_digits.assign(n, 0);
        ll temp_l = time_l;
        for (int j = n - 1; j >= 0; --j) {
            if (W[j] != INF && W[j] > 0) {
                l_digits[j] = temp_l / W[j];
                temp_l %= W[j];
            }
        }
        
        ll result_time = dfs(n - 1, target_h, true);

        if (result_time > time_r) {
            cout << -1 << endl;
        } else {
            cout << result_time << endl;
        }
    }
}

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

复杂度分析

  • 时间复杂度: O(NCmax+QNCmax)O(N \cdot C_{max} + Q \cdot N \cdot C_{max})

    • 预处理: 我们需要计算权重 WW 和后缀最大伤害 max_damage_suffix,这部分需要遍历所有齿轮和齿,复杂度是 O(Ci)O(\sum C_i),可以记为 O(NCmax)O(N \cdot C_{max})
    • 查询: 对于每个查询,我们进行一次DFS。在最坏的情况下,搜索树的每个节点会扩展 CmaxC_{max} 个分支,深度为 NN。但由于我们有强大的剪枝和“找到一个解就返回”的优化,实际访问的节点数远小于理论上限。每个状态 (gear_idx, is_l_bound) 只会被有效计算一次,所以复杂度大约是 O(NCmax)O(N \cdot C_{max})。总共 QQ 次查询,所以是 O(QNCmax)O(Q \cdot N \cdot C_{max})
  • 空间复杂度: O(NCmax)O(N \cdot C_{max})

    • 我们需要存储所有齿轮的齿数和攻击值,空间为 O(Ci)O(\sum C_i),即 O(NCmax)O(N \cdot C_{max})
    • DFS的递归深度最多为 NN,所以栈空间是 O(N)O(N)
    • 总体空间由输入数据主导。

知识点总结

这道题真是一次有趣的冒险,我们从中可以学到不少东西呢!

  1. 混合基数系统: 能够识别出问题背后的数学模型是解题的第一步。这种变进制的思想在处理类似时钟、日历或者自定义计数系统的问题时非常有用。
  2. 数位DP思想: 虽然我们没有用严格的表格DP,但is_tight(或is_l_bound)这个标志是数位DP的核心思想。它帮助我们处理“不小于L”这类约束,将一个大范围的搜索问题,转化为对数字各位的精巧构造。
  3. 带剪枝的DFS: 对于有清晰结构的搜索空间,DFS是一个强大的工具。而有效的剪枝是决定算法效率的关键。本题中基于“未来最大收益”的剪枝是常见的优化手段。
  4. 贪心优化: 在搜索过程中,一旦我们脱离了下界 L 的束缚,之后的目标就变成了“在满足伤害的条件下,时间增量最小”。由于我们的搜索顺序是从小到大,第一个找到的解即为最优解,可以立即返回。这个贪心性质的优化极大地提升了效率。

希望这篇题解能对Master有所帮助,如果还有不明白的地方,随时可以再来问我哦,喵~ ฅ'ω'ฅ