Skip to content

E. Triple Operations - 题解

比赛与标签

比赛: Codeforces Round 964 (Div. 4) 标签: dp, implementation, math 难度: *1300

喵喵,题目在说什么呀?

主人你好呀~ 这道题是说,我们有一个写着从 lr 所有整数的板子。我们可以进行一种神奇的操作:从板子上挑两个数 xy,擦掉它们,然后写上 3x⌊y/3⌋(就是 y 除以 3 向下取整啦)。

我们的目标是,用最少的操作次数,让板子上所有的数字都变成 0。题目还很贴心地告诉我们,这总是能做到的哦!我们要做的就是找出这个最少的次数,喵~

输入会给我们 t 组测试数据,每组数据有一对 lr。我们要为每一组数据输出一个答案。

解题思路大揭秘,喵~

这道题的操作看起来有点复杂,一个数变大三倍,另一个数变小三倍。直接模拟的话,选择太多了,肯定会头晕的,的说。所以,我们需要找到问题的核心规律!

关键操作的分析

我们来仔细看看这个操作 (x, y) -> (3x, ⌊y/3⌋)

  • ⌊y/3⌋ 是让数字变小的关键,也是我们通向 0 的唯一途径。
  • 3x 则是这个操作的“副作用”,它会让一个数字变大,给我们之后的工作增加了一点点小麻烦。

如果我们能有一个 0 在板子上,事情就变得简单多啦!我们可以一直用 0 作为 x 来进行操作: (x=0, y=k) -> (3*0, ⌊k/3⌋) -> (0, ⌊k/3⌋) 看呐!这个操作可以把任意一个数 k 变成 ⌊k/3⌋,而且我们的 0 还在,不会产生新的非零数字。这简直是完美的“消除工具”,喵!

所以,我们的整个策略可以分成两个阶段:

  1. 想办法搞到第一个 0
  2. 用这个 0 作为工具,把所有其他数都变成 0

两阶段策略详解

阶段一:获得第一个 0 的“启动成本”

为了得到第一个 0,我们必须进行 (x, y) -> (..., ⌊y/3⌋) 这样的操作,直到某个 y 变成 0。注意到,当 y0, 1, 或 2 时,⌊y/3⌋ 就会等于 0。所以我们的目标是先在板子上制造出一个 0, 1, 或 2

我们定义一个函数 cost(k),表示把数字 k 变成 0, 1, 2 中任意一个,所需要的最少操作次数。

  • 如果 k 已经是 12,那 cost(k) = 0
  • 否则,我们需要一次操作把 k 变成 ⌊k/3⌋,然后继续解决 ⌊k/3⌋ 的问题。所以 cost(k) = 1 + cost(⌊k/3⌋)

为了让这个阶段的操作次数最少,我们应该选择 [l, r]cost 值最小的那个数开始。因为 cost(k) 是一个随 k 增大的不减函数,所以 cost(l) 就是 [l, r] 范围里的最小值。

所以,我们需要 cost(l) 次操作,把 l 变成一个 12(我们称之为“准零数”)。然后,再用一次操作,把这个“准零数”变成真正的 0。 总共的“启动成本”就是 cost(l) + 1 次操作。

阶段二:用 0 清理全场

现在我们有了第一个 0。接下来要怎么办呢?我们来思考一下,把一个数 k 完全变成 0 需要多少次 y -> ⌊y/3⌋ 这样的除法操作。 我们定义另一个函数 ops(k)

  • ops(0) = 0
  • ops(k) = 1 + ops(⌊k/3⌋) for k > 0

这个 ops(k) 表示把 k 彻底变成 0 需要的除法次数。

一个非常有趣的性质是:对于 x, y > 0 的操作 (x, y) -> (3x, ⌊y/3⌋),板子上所有数字的 ops 值之和是不变的! ops(3x) + ops(⌊y/3⌋) = (1 + ops(x)) + (ops(y) - 1) = ops(x) + ops(y) 这意味着,在第一阶段我们为了获得 0 而进行的所有 cost(l) + 1 次操作,虽然改变了板上的数字,但所有数字的 ops 值总和并没有减少!

初始时,所有数字的 ops 值总和是 Sum = Σ_{k=l to r} ops(k)。 经过第一阶段的 cost(l) + 1 次操作后,这个总和仍然是 Sum

但是,一旦我们有了 0,情况就变了! 每次我们执行 (0, k) -> (0, ⌊k/3⌋)ops 值总和的变化是: ops(0) + ops(⌊k/3⌋) - (ops(0) + ops(k)) = ops(k) - 1 - ops(k) = -1 总和减少了 1

所以,在第二阶段,我们有了一个 0,并且板上所有数的 ops 总和为 Sum。我们需要执行 Sum(0, k) 这样的操作,才能把这个总和降为 0,也就是让所有数都变成 0

最终的答案

总操作数 = 阶段一的操作数 + 阶段二的操作数 Total_Ops = (cost(l) + 1) + (Σ_{k=l to r} ops(k))

这就是我们的最终公式啦!为了快速计算,我们可以预处理出 1200000 所有的 ops(k)cost(k) 值,并用前缀和来快速计算 Σ ops(k)。这样每个查询就能在 O(1) 的时间内解决了,喵~

代码魔法时间!

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

// 设置 r 的最大值,再加一点点缓冲,喵~
const int MAX_R = 200005;

// 全局变量来存放预处理的结果,这样每次查询就不用重新算啦
std::vector<int> ops(MAX_R);
std::vector<int> cost(MAX_R);
std::vector<long long> prefix_ops(MAX_R);

// 预处理函数,在所有测试用例开始前只调用一次
void preprocess() {
    // ops[i] 表示把数字 i 变成 0 需要的 y -> floor(y/3) 操作次数
    // 递推关系是 ops[i] = 1 + ops[i/3]
    ops[0] = 0;
    for (int i = 1; i < MAX_R; ++i) {
        ops[i] = 1 + ops[i / 3];
    }

    // cost[i] 表示把数字 i 变成 1 或 2(我们叫它“准零数”)的最小操作次数
    // 递推关系是 cost[i] = 1 + cost[i/3],当 i >= 3 时
    cost[0] = 0; // 基础情况,虽然 l>=1 用不到
    cost[1] = 0; // 1 已经是“准零数”了
    cost[2] = 0; // 2 也是“准零数”
    for (int i = 3; i < MAX_R; ++i) {
        cost[i] = 1 + cost[i / 3];
    }

    // 计算 ops 数组的前缀和,方便快速查询区间和
    prefix_ops[0] = ops[0];
    for (int i = 1; i < MAX_R; ++i) {
        prefix_ops[i] = prefix_ops[i - 1] + ops[i];
    }
}

// 解决单个测试用例的函数
void solve() {
    int l, r;
    std::cin >> l >> r;

    // 所有初始数字 [l, r] 都变成 0 所需的 y -> floor(y/3) 操作总次数
    // 用预处理的前缀和可以 O(1) 算出
    long long sum_ops = prefix_ops[r] - prefix_ops[l - 1];

    // 获得第一个 0 的“启动成本”是 cost(l) + 1
    // cost(k) 是不减的,所以 [l, r] 中最小的 cost 就是 cost(l)
    int min_c = cost[l];

    // 总操作数 = 启动成本 + 清理全场的成本
    long long total_ops = (long long)min_c + 1 + sum_ops;
    
    std::cout << total_ops << "\n";
}

int main() {
    // 加速输入输出,让程序跑得更快,喵~
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    // 执行一次预处理
    preprocess();

    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }

    return 0;
}

复杂度分析喵

  • 时间复杂度: O(MAX_R + T) 的说。

    • preprocess 函数对 ops, cost, prefix_ops 数组进行填充,每个数组大小为 MAX_R,所以预处理的时间复杂度是 O(MAX_R)
    • 对于 T 个测试用例,solve 函数中的每一步都是 O(1) 的查表和计算。所以处理所有测试用例的时间是 O(T)
    • 总时间就是 O(MAX_R + T),非常高效!
  • 空间复杂度: O(MAX_R) 的说。

    • 我们用了三个大小为 MAX_R 的数组来存储预处理的结果,所以空间复杂度是 O(MAX_R)

知识点小鱼干~

这道题真有趣,融合了好几种思想呢!

  1. 问题分解: 最核心的思路就是把复杂的过程分解为“获得第一个0”和“用0清理全场”两个阶段。这种分而治之的思想在算法题里很常见哦。
  2. 不变量思想: 发现 Σ ops(k) 在非零操作下的不变量特性,是理解为什么需要分别计算两个阶段成本的关键。找到问题中的不变量,往往能大大简化问题!
  3. 动态规划与预处理: ops(k)cost(k) 的计算都是简单的动态规划。通过预处理,我们将每次查询的复杂度降到了常数级别,这是典型的空间换时间策略。
  4. 前缀和: 这是一个超级实用的小技巧,能把区间求和问题从 O(N) 优化到 O(1),一定要掌握呀!

希望这篇题解能帮助到你,主人!解题就像吃小鱼干,找到窍门就特别香,喵~ 继续加油哦!

Released under the MIT License.