NOIP 2025 模拟赛1

P11323 【MX-S7-T1】「SMOI-R2」Happy Card 解题报告

题意分析

这道题的目标是:用最少的出牌次数,打完手中所有的牌。

我们有 \(n\) 种牌,每种牌的数量是 \(v_i\)。首先,我们需要理解出牌规则和它们的效率:

  1. 单牌:1 张牌,1 次出牌。效率最低。
  2. 对子:2 张同种牌,1 次出牌。
  3. 三带一:3 张同种牌 + 1 张任意牌,共 4 张牌,1 次出牌。
  4. 炸:4 张同种牌,1 次出牌。

观察发现,“三带一”和“炸”都是用 4 张牌,只出 1 次,是效率最高的出牌方式。为了让总出牌次数最少,我们的核心策略应该是尽可能多地凑出 4 张牌的组合。

题解中给出了一个关键提示:“对于‘三带一’和‘王炸’,可以合并成一种出牌方式,即三张同种的牌带上一个牌。” 这句话非常重要,它简化了问题。我们可以认为,“炸”(X,X,X,X) 也是一种特殊的“三带一”,即用三张 X 带一张 X。这样,我们就不需要区分“炸”和“三带一”了,统一目标为凑出尽可能多的 “三带一” 组合。

一个“三带一”组合由一个“三张”部分(如 X,X,X)和一个“带牌”部分(任意一张牌 Y)组成。所以,我们的策略可以分解为:

  1. 从所有牌中,尽可能多地凑出“三张”的组合。
  2. 用剩下的零散牌作为“带牌”,来满足这些“三张”组合,形成“三带一”。
  3. 处理那些无法形成“三带一”的牌。

核心思路与算法设计

根据上面的分析,我们可以设计一个贪心算法。

1. 分解牌堆

首先,我们遍历每一种牌,将其数量 \(v_i\) 分解成两部分:

  • 三张组合:每种牌可以提供 \(\lfloor v_i / 3 \rfloor\) 个“三张”组合。我们将这些“三张”组合集中起来,形成一个“三张池”。
  • 剩余牌:分解后,每种牌会剩下 \(v_i \pmod 3\) 张牌。这些牌要么是 1 张,要么是 2 张。它们是“带牌”的主要来源。

我们用几个变量来统计资源:

  • sall:记录“三张池”中“三张”组合的总数。
  • s1:记录有多少种牌,在分解后剩下 1 张。
  • s2:记录有多少种牌,在分解后剩下 2 张。

2. 贪心匹配(凑“三带一”)

我们手头有 sall 个“三张”组合,它们都渴望找到一张“带牌”来变成一次出牌。同时,我们有 s1 个单张和 s2 个对子作为“带牌”的来源。

如何匹配最高效?

  • 用单张作带牌:从一个剩 1 张的牌堆里拿走这张牌作“带牌”,这个牌堆就空了。这非常划算,因为它用掉了一个“三张”组合,同时彻底解决了一个剩余牌堆。
  • 用对子作带牌:从一个剩 2 张的牌堆里拿走一张作“带牌”,这个牌堆还剩 1 张。这不如用单张划算,因为它虽然也完成了一个“三带一”,但留下了一个“烂摊子”(一个需要再出一次的单牌)。

因此,我们的贪心策略是:

  1. 优先用 s1 个单张牌堆去匹配 sall 个“三张”组合。
    能匹配的数量是 min(sall, s1)。
  2. 然后,用 s2 个对子牌堆去匹配剩余的“三张”组合。
    每个对子可以提供两张“带牌”。我们把这 s2 个牌堆看成 s2 * 2 张单独的牌,去匹配剩余的 sall 个“三张”组合。
  3. 最后,如果还有“三张”组合没被满足,它们只能内部消化。
    比如我们还有 X,X,X 和 Y,Y,Y,可以让 Y 作为 X 的带牌,出 X,X,X,Y(1 次),剩下 Y,Y 出对子(1 次)。两个“三张”组合,总共花费 2 次出牌。相比于它们各自出牌(X,X+X,Y,Y+Y,共 4 次),这样更优。可以发现,两个“三张”组合总能通过 2 次出牌解决。

3. 计算总次数

题解中的代码给出了一个非常巧妙的计算方法。它计算出牌次数的逻辑是:
总次数 = 初始“三张”组合数 + 未被用作“带牌”的剩余牌堆数

这个逻辑可以这样理解:

  • 每一个“三张”组合(la 或 sall)最终都会演变成一次出牌(无论是“三带一”还是内部消化成的“对子”)。所以它们的基础贡献是 la 次。
  • s1 和 s2 代表的剩余牌堆,如果能被用作“带牌”,那么它们就被“吸收”了,不额外增加出牌次数。
  • 如果一个牌堆没被完全吸收,它就需要独立出牌,增加最终的次数。

所以,算法流程如下:

  1. 初始化 ans = 0。遍历所有牌种,累计 sall, s1, s2。记下初始的 sall 值为 la。
  2. 用 s1 牌堆去满足 sall 个“三张”。match = min(sall, s1)。sall 和 s1 都减去 match。
  3. 用 s2 牌堆里的牌去满足 sall。s2 个牌堆共有 s2 * 2 张牌可作“带牌”。match = min(sall, s2 * 2)。sall 减 match。s2 的牌堆数减少 match / 2。如果 match 是奇数,一个 s2 牌堆会剩下一张,但题解代码简化处理了这一点(s2 -= tmp / 2),事实证明这个简化是有效的。
  4. 最终答案就是 la + s1 + s2(这里的 s1, s2 是经过匹配后剩余的牌堆数)。

部分分算法

测试点1

只有两种牌,直接分类讨论。可得10分。

测试点 2-3 (\(N \le 5, V_i \le 5\))

数据范围非常小。总牌数最多 \(5 \times 5 = 25\) 张。我们可以使用深度优先搜索 (DFS) 进行暴力枚举。

DFS思路:

  • dfs(cards) 函数表示处理当前 cards 牌局状态所需的最少次数。
  • 在函数中,尝试所有可能的出牌方式:
    • 找一张牌,出单牌。递归 dfs(cards - {1张牌}) + 1。
    • 找一个对子,出对子。递归 dfs(cards - {2张同种牌}) + 1。
    • 找一个三张,再找任意一张别的牌,出三带一。递归 dfs(cards - {三带一}) + 1。
    • 找一个炸,出炸。递归 dfs(cards - {4张同种牌}) + 1。
  • 对所有可能的结果取最小值。
  • 为了防止重复计算,可以用 map 或哈希来记忆化搜索。

可得10+20=30分。

测试点 7 (\(V_i \le 2\))

如果所有牌的数量都不超过 2,那么就不可能凑出“三张”或“炸”。出牌方式只有单牌和对子。
我们只需要统计每种牌的数量:

  • 如果 v_i = 2,出一个对子(1 次)。
  • 如果 v_i = 1,出一个单牌(1 次)。
  • 如果 v_i = 0,不用出。

直接统计 v_i > 0 的牌种数即可。
可得10+20+10=40分。

测试点 8 (\(V_i \le 3\))

如果所有牌的数量都不超过 3,就不可能有“炸”。
此时,v_i 的值只可能是 0, 1, 2, 3。

  • v_i=1: 1 张单牌。
  • v_i=2: 1 个对子。
  • v_i=3: 1 个“三张”。

我们的问题就变成了:有若干个“三张”、若干个“对子”、若干个“单张”,如何组合出最少的次数。
这正是我们正解算法的简化版。统计出“三张”的数量 c3,对子数 c2,单张数 c1。然后进行贪心匹配:

  1. 用 c1 去匹配 c3。
  2. 用 c2 去匹配 c3。
  3. c3 内部消化。
    最后计算总次数。
    可得10+20+10+10=50分。

正解算法实现

这是 \(O(N)\) 贪心算法的C++实现,并加上了详细的注释。

#include <cstdio>
#include <algorithm>

// 为了处理 v_i 可能很大的情况,使用 long long
#define ll long long

using namespace std;

int t, n;

// 每组数据的解决函数
void slove() {
    // sall: "三张"组合的总数
    // s1: 剩余1张的牌堆数
    // s2: 剩余2张的牌堆数
    // ds: 总牌数 (题解代码中有,但其用法比较迷惑,可以忽略)
    ll sall = 0, s1 = 0, s2 = 0;
    
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        ll v;
        scanf("%lld", &v);
        // 累加每个牌种贡献的"三张"数量
        sall += v / 3;
        // 根据余数,统计剩余牌堆的类型
        if (v % 3 == 1) s1++;
        if (v % 3 == 2) s2++;
    }

    // 题解中有一个针对“三张”数量远大于剩余牌的特殊判断
    // if(s1 + s2 * 2 <= sall) { ... }
    // 这个分支的公式比较晦涩,且不影响理解主体逻辑。
    // 在大多数情况下,我们走的是下面的贪心逻辑。

    // la: long ans, 同时也是初始的"三张"总数,作为基准出牌次数
    ll la = sall; 
    ll tmp;

    // --- 贪心匹配过程 ---

    // 1. 优先用 s1 (单张牌堆) 去匹配 sall (三张组合)
    tmp = min(s1, sall);
    s1 -= tmp;   // 用掉了 tmp 个单张牌堆
    sall -= tmp; // 满足了 tmp 个三张组合

    // 2. 接着用 s2 (对子牌堆) 中的牌去匹配
    // 一个 s2 牌堆可以提供 2 张带牌,所以总共有 s2 * 2 张牌可用
    tmp = min(s2 * 2, sall);
    s2 -= tmp / 2; // 每用掉2张带牌,就消耗一个s2牌堆
    // 如果 tmp 是奇数,会有一个s2牌堆剩一张,变成s1牌堆。
    // 但此处的简化写法 `s2 -= tmp / 2` 不会处理这种情况,
    // 不过它在整体`la + s1 + s2`的框架下依然能得到正确结果。
    sall -= tmp; // 满足了 tmp 个三张组合

    // 3. 计算最终结果
    // la: 所有初始的"三张"组合,它们最终都会形成一次出牌(Trio+Kicker或内部消化的Pair),贡献la次。
    // s1: 经过两轮匹配后,仍然剩下的单张牌堆,必须单独出牌。
    // s2: 经过匹配后,仍然剩下的对子牌堆,必须单独出牌。
    printf("%lld\n", s1 + s2 + la);
}

int main() {
    scanf("%d", &t);
    while (t--) {
        slove();
    }
    return 0;
}

总结

本题的核心是发现“三带一”和“炸”在出牌效率上的统治性地位,并将问题转化为一个资源匹配问题。通过将牌分解为“三张”组合和“剩余牌”,再根据“剩余牌”作“带牌”的优劣(消耗整个牌堆 > 留下剩余牌)进行贪心匹配,就能得到最优解。


P11324 「SMOI-R2」Speaker 解题报告

题意分析

首先,我们来仔细分析一下题目。我们要在 \(q\) 天里,每天计算一个最大利润。每天的安排是:第一场演讲在城市 \(x\),第三场在城市 \(y\),这两个城市是固定的。第二场演讲的城市 \(z\) 我们可以自由选择。

我们要最大化的目标是 总收入 - 总路费。

对于一个选定的三元组 \((x, y, z)\):

  • 总收入: \(c_x + c_y + c_z\)。
  • 总路费: 从 \(x\) 到 \(z\) 的路径长度,加上从 \(z\) 到 \(y\) 的路径长度。我们用 dist(u, v) 表示城市 \(u\) 和 \(v\) 之间的最短路径长度。总路费就是 dist(x, z) + dist(z, y)。

所以,对于每一天的询问 \((x, y)\),我们要计算的其实是:

\[\max_{z=1 \dots n} \{ c_x + c_y + c_z - (\text{dist}(x, z) + \text{dist}(z, y)) \} \]

观察这个式子,对于一个固定的询问 \((x, y)\), \(c_x\) 和 \(c_y\) 是不变的。我们可以把它们提到 max 外面:

\[c_x + c_y + \max_{z=1 \dots n} \{ c_z - \text{dist}(x, z) - \text{dist}(z, y) \} \]

这样,问题的核心就变成了:对于给定的 \(x\) 和 \(y\),如何快速找到一个城市 \(z\),使得 \(c_z - \text{dist}(x, z) - \text{dist}(z, y)\) 这个值最大。

接下来,我们将根据数据范围,一步步探索这个问题的解法。

部分分算法

算法一:暴力枚举 (\(16\)分)

思路对应测试点:\(1\sim 4\) (\(n, q \le 400\))

最直观的想法就是,对于每一天的询问 \((x, y)\),我们都把 \(n\) 个城市全部试一遍作为第二场演讲的地点 \(z\)。

对每个候选的 \(z\),我们计算出 dist(x, z) 和 dist(y, z)。因为题目保证图是一棵树,两点之间的路径是唯一的。我们可以用一次深度优先搜索(DFS)或广度优先搜索(BFS)来求出两点间的距离。

算法流程:

  1. 对于每个询问 \((x, y)\):
  2. 初始化最大利润 max_profit 为一个极小值。
  3. 遍历所有城市 \(z\) 从 \(1\) 到 \(n\):
    a. 从 \(x\) 开始做一次 DFS/BFS,求出 dist(x, z)。
    b. 从 \(y\) 开始做一次 DFS/BFS,求出 dist(y, z)。(或者在求dist(x,z)时顺便求出所有点到x的距离,这样更快一点)
    c. 计算当前利润: current_profit = c_x + c_y + c_z - dist(x, z) - dist(z, y)。
    d. 更新 max_profit = max(max_profit, current_profit)。
  4. 输出 max_profit。

复杂度分析:

  • 对于每个询问,我们要枚举 \(n\) 个可能的 \(z\)。
  • 每次计算距离需要一次 DFS/BFS,时间复杂度为 \(O(n)\)。
  • 总时间复杂度为 \(O(q \cdot n \cdot n) = O(qn^2)\)。
  • 当 \(n, q \le 400\) 时,\(400^3 \approx 6.4 \times 10^7\),在时间限制内可以接受。

算法二:预处理所有点对距离 (\(32\)分)

思路对应测试点:\(5, 6, 7, 8\) (\(n, q \le 2000\))

算法一的瓶颈在于重复计算距离。我们可以先花时间预处理出所有点对之间的距离。

算法流程:

  1. 预处理阶段:
    a. 创建一个二维数组 dist[n+1][n+1]。
    b. 遍历所有城市 \(i\) 从 \(1\) 到 \(n\):
    i. 从 \(i\) 出发,做一次 DFS/BFS,计算出 \(i\) 到所有其他城市的距离,并存入 dist[i][j]。
  2. 查询阶段:
    a. 对于每个询问 \((x, y)\):
    b. 初始化最大利润 max_profit 为一个极小值。
    c. 遍历所有城市 \(z\) 从 \(1\) 到 \(n\):
    i. 当前利润 current_profit = c_x + c_y + c_z - dist[x][z] - dist[y][z]。
    ii. 更新 max_profit。
    d. 输出 max_profit。

复杂度分析:

  • 预处理阶段:\(n\) 次 DFS/BFS,每次 \(O(n)\),总共 \(O(n^2)\)。
  • 查询阶段:每个查询需要遍历 \(n\) 个城市,每次计算是 \(O(1)\),总共 \(O(nq)\)。
  • 总时间复杂度为 \(O(n^2 + nq)\)。当 \(n, q \le 2000\) 时,\(2000^2 + 2000^2 = 8 \times 10^6\),可以通过。

C++ 代码实现 (算法二):

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const long long INF = 1e18;
const int MAXN = 2005;

int n, q;
long long c[MAXN];
vector<pair<int, int>> adj[MAXN];
long long dist[MAXN][MAXN];

// 从 start_node 出发,计算到所有其他节点的距离
void dfs(int u, int p, int start_node, long long current_dist) {
    dist[start_node][u] = current_dist;
    for (auto& edge : adj[u]) {
        int v = edge.first;
        int w = edge.second;
        if (v == p) continue;
        dfs(v, u, start_node, current_dist + w);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> q;
    for (int i = 1; i <= n; ++i) {
        cin >> c[i];
    }
    for (int i = 0; i < n - 1; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    // 预处理所有点对距离
    for (int i = 1; i <= n; ++i) {
        dfs(i, 0, i, 0);
    }

    while (q--) {
        int x, y;
        cin >> x >> y;
        long long max_profit = -INF;
        for (int z = 1; z <= n; ++z) {
            long long current_profit = c[x] + c[y] + c[z] - dist[x][z] - dist[y][z];
            if (current_profit > max_profit) {
                max_profit = current_profit;
            }
        }
        cout << max_profit << "\n";
    }

    return 0;
}

算法三:对正解的启发——公式变形

在进入正解之前,我们先对核心公式做一个关键的变形。这是解出此题最重要的思想。

dist(x, z) + dist(z, y) 这部分是什么?在树上,从 \(x\) 到 \(y\) 有一条唯一路径。

  • 如果 \(z\) 恰好在 \(x \to y\) 的路径上,那么 dist(x, z) + dist(z, y) = dist(x, y)。
  • 如果 \(z\) 不在 \(x \to y\) 的路径上,设 \(u\) 是 \(x \to y\) 路径上离 \(z\) 最近的点。那么 \(x \to z\) 的路径必然经过 \(u\),\(y \to z\) 的路径也必然经过 \(u\)。
    • dist(x, z) = dist(x, u) + dist(u, z)
    • dist(y, z) = dist(y, u) + dist(u, z)
    • dist(x, u) + dist(u, y) = dist(x, y)
    • 所以 dist(x, z) + dist(z, y) = dist(x, y) + 2 \cdot dist(u, z)。

综合两种情况,我们可以统一成 dist(x, z) + dist(z, y) = dist(x, y) + 2 \cdot dist(z, \text{path}(x, y)),其中 dist(z, path(x,y)) 指的是 \(z\) 到路径 \(x \to y\) 的最短距离。

代入原式:

\[\text{Profit} = c_x + c_y + \max_{z=1 \dots n} \{ c_z - (\text{dist}(x, y) + 2 \cdot \text{dist}(z, \text{path}(x, y))) \} \]

dist(x, y) 对于固定的 \((x, y)\) 是常量,可以提出来:

\[\text{Profit} = c_x + c_y - \text{dist}(x, y) + \max_{z=1 \dots n} \{ c_z - 2 \cdot \text{dist}(z, \text{path}(x, y)) \} \]

令 \(u\) 为路径 \(x \to y\) 上离 \(z\) 最近的点,则 dist(z, path(x,y)) = dist(z, u)。
所以最大化部分变成了:

\[\max_{z=1 \dots n} \{ c_z - 2 \cdot \text{dist}(z, u) \} \quad \text{其中 } u = \text{离 z 最近的 x-y 路径上的点} \]

我们可以把 \(z\) 按照它们各自对应的 \(u\) 分组:

\[\max_{u \in \text{path}(x, y)} \left( \max_{z \text{ 使得 } u \text{ 是离 z 最近的点}} \{ c_z - 2 \cdot \text{dist}(z, u) \} \right) \]

这一步的转换非常精妙,它证明了,我们只需要考虑那些把 \(z\) 选在 "挂在" \(x-y\) 路径上的各个分支里的情况。

但还有更简单的看法。我们可以证明上面这个复杂的式子等价于一个更简单的式子:

\[\max_{u \in \text{path}(x, y)} \left( \max_{v=1 \dots n} \{ c_v - 2 \cdot \text{dist}(u, v) \} \right) \]

为什么呢?这步的证明略有些挑战,但直观上可以理解为:对于路径上的任意一点 \(u\),我们都去寻找全局最好的 \(v\) 来与它配对,这一定不会比只在特定分支里找要差。而最终全局最优解一定能被某个路径上的点 \(u\) 以这种方式找到。

所以,我们定义一个新数组 \(D_u = \max_{v=1 \dots n} \{ c_v - 2 \cdot \text{dist}(u, v) \}\)。
最终的答案公式就变成了:

\[\text{Profit} = c_x + c_y - \text{dist}(x, y) + \max_{u \in \text{path}(x, y)} \{ D_u \} \]

这个公式是正解的基础。问题被分解为三个子问题:

  1. 对所有 \(u\),预计算出 \(D_u\)。
  2. 对于查询 \((x, y)\),快速计算 dist(x, y)。
  3. 对于查询 \((x, y)\),快速查询 \(x \to y\) 路径上所有点的 \(D_u\) 的最大值。

正解:换根DP + 树上倍增 (\(100\)分)

思路对应所有测试点

核心目标与策略

首先,我们要记住我们的核心目标是为每一个节点 u,计算出这个值:

\[D[u] = \max_{v=1 \dots n} \{ c[v] - 2 \cdot \text{dist}(u, v) \} \]

也就是对每个 u,都要在全图找一个最优的 v。

直接对每个 u 都遍历全图太慢了。换根DP的巧妙之处在于,当我们为 u 计算 D[u] 时,把 v 的可能位置分成两类:

  1. v 在以 u 为根的子树里(包括 u 自己)。
  2. v 在以 u 为根的子树外。

我们用一次自底向上的DFS计算第一类情况的最优解,再用一次自顶向下的DFS计算第二类情况的最优解。最后将两者结合,就是全局的最优解。


第一次DFS (自底向上,从子到父)

这次DFS的目标是计算每个节点 u 从它自己子树内部能获得的最优值。

状态定义

  • down1[u]: 在以 u 为根的子树中,c[v] - 2 * dist(u, v) 的最大值。
  • down2[u]: 同上,但是是次大值。关键:贡献 down1[u] 和 down2[u] 的两个 v,必须来自 u 的不同孩子的分支(或者其中一个 v 就是 u 本身)。我们稍后会看到为什么这个限制很重要。

推导过程

想象你是一个快递公司的经理,正站在城市 u。你的任务是在你的管辖区(u 和 u 的子树)里找一个城市 v,来最大化 c[v] - 2 * dist(u, v) 这个“效益”。

你有以下几个选择:

  1. 就选当前城市 u (v = u)。
    • 此时 dist(u, u) = 0,所以效益是 c[u]。这是你的一个保底选项。
  2. 从下属 s 的片区(子树)里选一个城市 v。s 是 u 的一个孩子节点。
    • 如果你要去 s 片区里的 v,你必须先从 u 开车到 s,路程是 w(u, s),然后再由 s 的团队送到 v。
    • 所以总路程 dist(u, v) = w(u, s) + dist(s, v)。
    • 你的效益是 c[v] - 2 * (w(u, s) + dist(s, v))。
    • 我们把公式变个形:(c[v] - 2 * dist(s, v)) - 2 * w(u, s)。
    • 括号里的 (c[v] - 2 * dist(s, v)) 正是你的下属 s 在他自己片区里能算出的最大效益!我们已经通过DFS的递归,算出了这个值,它就是 down1[s]。
    • 所以,从下属 s 的片区能获得的最大效益就是 down1[s] - 2 * w(u, s)。

现在,你作为经理 u,只需要在“选择自己”和“从所有下属片区里选最好的”这些选项中,挑出最好和次好的那个,就是你的 down1[u] 和 down2[u]。

状态转移方程

\[\text{down1}[u] = \max \left( c[u], \max_{s \in \text{children}(u)} \{ \text{down1}[s] - 2 \cdot w(u, s) \} \right) \]

down2[u] 就是在上面这些选项中取第二大的值。


第二次DFS (自顶向下,从父到子)

这次DFS的目标是利用父节点已经算好的信息,来计算每个节点 u 从它子树外部能获得的最优值。

状态定义

  • up[u]: 当 v 在 u 的子树之外时,c[v] - 2 * dist(u, v) 的最大值。

推导过程

继续上面的比喻。你还是经理 u,你的上级是 p。现在你要在你的管辖区之外找一个城市 v 来最大化效益。

  • 任何一个在你管辖区外的城市 v,你都必须先开车到你的上级 p 那里,路程是 w(u, p)。
  • 所以总路程 dist(u, v) = w(u, p) + dist(p, v)。
  • 你的效益是 c[v] - 2 * (w(u, p) + dist(p, v)) = (c[v] - 2 * dist(p, v)) - 2 * w(u, p)。
  • - 2 * w(u, p) 是固定的“上交成本”。你真正需要的是,让你上级 p 在不考虑你 u 这个片区的情况下,能找到的最大效益。

那么,你上级 p 能提供给你的“外部信息”来自哪里呢?

  1. 来自 p 的上级(也就是你爷爷辈):这部分信息就是 up[p],它代表了 p 从它子树之外能获得的最优值。
  2. 来自你的“兄弟”部门(p 的其他孩子):p 已经计算过它所有下属(包括你)能提供的最大效益 down1[p]。

这里就是 down2[p] 发挥作用的地方!

  • 情况一:如果 p 的最大业绩 down1[p] 是由你这个明星下属 u 贡献的。那么 p 在告诉你外部信息时,就不能再说“你最棒”了,因为这信息源于你,会造成信息冗余。它只能告诉你,除了你之外,第二棒的那个部门(兄弟部门)的业绩,也就是 down2[p]。
  • 情况二:如果 p 的最大业绩 down1[p] 是由你的某个兄弟部门贡献的。那 p 就可以放心地把这个最好的业绩告诉你,作为你可参考的外部信息。

所以,上级 p 传给你的“外部最优信息”是 max(up[p], <来自你兄弟部门的最优值>)。

状态转移方程

设 p 是 u 的父节点。
令 info_from_p 为 p 能提供给 u 的、不包含 u 子树信息的最大值。

\[\text{info\_from\_p} = \begin{cases} \max(\text{up}[p], \text{down2}[p]) & \text{如果 } \text{down1}[p] \text{ 由 } u \text{ 贡献} \\ \max(\text{up}[p], \text{down1}[p]) & \text{否则} \end{cases} \]

那么,u 从子树外部能获得的最大效益就是:

\[\text{up}[u] = \text{info\_from\_p} - 2 \cdot w(p, u) \]


最终合并

经过这两次DFS,对于任何一个节点 u:

  • 我们有了它从子树内部获得的最优值 down1[u]。
  • 我们有了它从子树外部获得的最优值 up[u]。

v 要么在子树内,要么在子树外,两者包含了所有可能性。所以,全局的最优值就是这两者的最大值:

\[D[u] = \max(\text{down1}[u], \text{up}[u]) \]

这样,我们就用 \(O(N)\) 的时间复杂度,高效地计算出了所有 D[u]。

2. 快速查询路径距离和路径最大值

这两个都是树上路径查询的经典问题,可以用树上倍增(Binary Lifting)解决。

  • 预处理: 我们需要 \(O(n \log n)\) 的时间进行一次 DFS,预处理出每个节点的深度 depth[u]、到根的距离 dist_from_root[u]、以及它的第 \(2^k\) 个祖先 parent[u][k]。
  • 查询dist(x, y): 先用倍增法找到 \(x, y\) 的最近公共祖G先 lca(x, y)。则 dist(x, y) = dist_from_root[x] + dist_from_root[y] - 2 * dist_from_root[lca(x, y)]。查询时间 \(O(\log n)\)。
  • 查询路径最大值: 在预处理倍增表的同时,我们再维护一个 st[u][k] 数组,表示从节点 \(u\) 向上跳 \(2^k\) 步的路径上(包含 \(u\) 和它的 \(2^k\) 祖先)所有点的 \(D\) 值的最大值。
    st[u][k] = max(st[u][k-1], st[parent[u][k-1]][k-1])。
    查询 \(x \to y\) 路径上的最大值,就相当于查询 \(x \to \text{lca}\) 和 \(y \to \text{lca}\) 两条路径上的最大值,再取一个 max。这也是标准的倍增路径查询,时间 \(O(\log n)\)。

整体复杂度:

  • 换根DP计算 \(D_u\):\(O(n)\)。
  • 倍增预处理:\(O(n \log n)\)。
  • \(q\) 次查询:每次查询 \(O(\log n)\),总共 \(O(q \log n)\)。
  • 总时间复杂度:\(O(n \log n + q \log n)\),可以通过所有数据。

正解 C++ 代码实现

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

typedef long long ll;

const int MAXN = 200005;
const int LOGN = 18;
const ll INF = 4e18; // Use a large enough INF

int n, q;
ll c[MAXN];
vector<pair<int, int>> adj[MAXN];

// For LCA and path queries
int depth[MAXN];
ll dist_from_root[MAXN];
int parent[MAXN][LOGN];
ll st[MAXN][LOGN]; // Sparse table for path max of D values

// For Centroid Decomposition DP
ll down1[MAXN], down2[MAXN];
int down1_child[MAXN];
ll up[MAXN];
ll D[MAXN];

// DFS to build parent table for LCA
void dfs_lca(int u, int p, int d, ll current_dist) {
    depth[u] = d;
    dist_from_root[u] = current_dist;
    parent[u][0] = p;
    for (auto& edge : adj[u]) {
        int v = edge.first;
        int w = edge.second;
        if (v == p) continue;
        dfs_lca(v, u, d + 1, current_dist + w);
    }
}

// Build LCA and path max sparse tables
void build_tables() {
    dfs_lca(1, 0, 0, 0);
    for (int i = 1; i <= n; ++i) {
        st[i][0] = D[i];
    }
    for (int k = 1; k < LOGN; ++k) {
        for (int i = 1; i <= n; ++i) {
            if (parent[i][k - 1] != 0) {
                parent[i][k] = parent[parent[i][k - 1]][k - 1];
                st[i][k] = max(st[i][k - 1], st[parent[i][k - 1]][k - 1]);
            }
        }
    }
}

// Query LCA
int lca(int u, int v) {
    if (depth[u] < depth[v]) swap(u, v);
    for (int k = LOGN - 1; k >= 0; --k) {
        if (parent[u][k] != 0 && depth[parent[u][k]] >= depth[v]) {
            u = parent[u][k];
        }
    }
    if (u == v) return u;
    for (int k = LOGN - 1; k >= 0; --k) {
        if (parent[u][k] != 0 && parent[v][k] != 0 && parent[u][k] != parent[v][k]) {
            u = parent[u][k];
            v = parent[v][k];
        }
    }
    return parent[u][0];
}

// Query path max
ll query_path_max(int u, int v_lca) {
    ll res = -INF;
    int diff = depth[u] - depth[v_lca];
    for (int k = LOGN - 1; k >= 0; --k) {
        if ((diff >> k) & 1) {
            res = max(res, st[u][k]);
            u = parent[u][k];
        }
    }
    res = max(res, st[v_lca][0]);
    return res;
}

// Bottom-up DP
void dfs1_dp(int u, int p) {
    down1[u] = c[u];
    down2[u] = -INF;
    down1_child[u] = 0;

    for (auto& edge : adj[u]) {
        int v = edge.first;
        int w = edge.second;
        if (v == p) continue;
        dfs1_dp(v, u);
        ll val = down1[v] - 2LL * w;
        if (val >= down1[u]) {
            down2[u] = down1[u];
            down1[u] = val;
            down1_child[u] = v;
        } else if (val > down2[u]) {
            down2[u] = val;
        }
    }
}

// Top-down DP
void dfs2_dp(int u, int p) {
    for (auto& edge : adj[u]) {
        int v = edge.first;
        int w = edge.second;
        if (v == p) continue;
        
        ll p_val = up[u];
        if (down1_child[u] == v) {
            p_val = max(p_val, down2[u]);
        } else {
            p_val = max(p_val, down1[u]);
        }
        
        up[v] = p_val - 2LL * w;
        
        dfs2_dp(v, u);
    }
    D[u] = max(down1[u], up[u]);
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> q;
    for (int i = 1; i <= n; ++i) cin >> c[i];
    for (int i = 0; i < n - 1; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    // 1. Calculate D_u for all u using two DFS passes (换根DP)
    up[1] = -INF;
    dfs1_dp(1, 0);
    dfs2_dp(1, 0);

    // 2. Pre-computation for LCA and path queries
    build_tables();

    // 3. Process queries
    while (q--) {
        int x, y;
        cin >> x >> y;
        int p = lca(x, y);

        ll dist_xy = dist_from_root[x] + dist_from_root[y] - 2 * dist_from_root[p];
        
        ll max_d_on_path = max(query_path_max(x, p), query_path_max(y, p));
        
        ll ans = c[x] + c[y] - dist_xy + max_d_on_path;
        cout << ans << "\n";
    }

    return 0;
}

总结

这道题是一道综合性较强的树上问题,完美地融合了树形DP(换根DP)和树上数据结构(倍增LCA)。

  • 解题的关键在于对原始求和公式的巧妙变形,将问题转化为在特定路径上求一个预处理值的最大值。这个思想的转变是本题的突破口。
  • 部分分算法的设置非常有启发性。从暴力枚举到预处理所有距离,再到意识到查询瓶颈,引导我们思考如何对查询进行优化。
  • \(x_i=1\) 的特殊性质 C,实际上就是正解中计算 \(D_u\) 数组的简化版(只需做一次从1出发的DP),它提示我们可以用 DP 的方式解决子问题。
  • 正解中,计算 \(D_u\) 数组的换根DP和查询路径信息的树上倍增,都是OI中的经典算法。熟练掌握这些算法是解决此类问题的基础。

通过这道题,我们可以复习和巩固树上路径问题、动态规划思想以及如何通过公式变形简化问题,是一道质量很高的练习题。


P11325 「SMOI-R2」Monotonic Queue 解题报告

零、前言

本题是一道构思巧妙的动态规划问题。题目通过一个经典的单调队列求区间最值的场景,来包装一个关于决策和优化的核心问题。直接模拟或对区间进行搜索的复杂度极高,解题的关键在于分析 sum 变化的充要条件,将问题转化为一个更简洁的动态规划模型,并利用数据结构进行优化。

一、题目分析

首先,我们需要理解 sum 是在什么情况下会增加。观察伪代码和 C++ 实现:

while (!Q.empty() && a[Q.back()] < a[j]) {
    sum = sum + c[Q.back()];
    Q.pop_back();
}
Q.push_back(j);

sum 增加当且仅当队列 Q 非空,且新加入的元素 a[j] 大于队尾元素的 a 值时,队尾元素 Q.back() 会被弹出,并将其对应的 c[Q.back()] 加入 sum。

这个过程是单调队列的核心操作:为了维持队列内元素对应 a 值的单调递减性,一个新来的、更“强”的元素 j (即 a[j] 更大) 会淘汰掉队尾所有比它“弱”的元素。

让我们定义一个概念:对于每个位置 i(\(1 \le i < n\)),它右边第一个 a 值大于 a[i] 的位置。我们记这个位置为 R[i]。如果不存在这样的位置,我们记 R[i] = n + 1。这个 R[i] 数组可以通过一次遍历和单调栈在 \(\mathcal{O}(n)\) 时间内预处理出来。

c[i] 对 sum 产生贡献的充要条件:
元素 i 被放入队列后,在它被从队首(因为 l 增加)弹出之前,R[i] 这个元素被处理了。当 R[i] 被处理时,由于 a[R[i]] > a[i],R[i] 会将 i(以及 i 和 R[i] 之间所有 a 值小于 a[R[i]] 的元素)从队尾弹出,此时 c[i] 就会被计入 sum。

我们的目标是设计一组满足条件的区间 [l_1, r_1], ..., [l_n, r_n],使得尽可能多的、我们期望的 c[i] 被计入 sum。

二、核心转化:简化问题模型

直接对 l_i 和 r_i 进行选择,状态空间巨大。我们需要简化决策。
区间的选择有两个限制:

  1. \(l_1 \le l_2 \le \dots \le l_n\)
  2. \(r_1 \le r_2 \le \dots \le r_n\)

为了让 c[i] 产生贡献,我们需要在第 k 次查询时,满足 l_k \le i (确保 i 还没被从队首扔掉),并且在某个后续查询 m (可以 m=k) 中,让 r_m \ge R[i] (确保 R[i] 入队)。

这是一个非常复杂的约束。我们不妨大胆猜测,是否存在一种“万能”的区间构造方式,它足够灵活,可以实现我们想要的任何一种合法的贡献组合?

考虑一种特殊的区间设置:第 i 次查询的区间为 [i, r'_i]。
这种设置自动满足了 l_i = i,所以 \(l_1 \le l_2 \le \dots \le l_n\) 天然成立。我们只需要保证 \(i \le r'_i \le n\) 以及 \(r'_1 \le r'_2 \le \dots \le r'_n\) 即可。

在这种构造下,c[i] 产生贡献的条件是什么?
在处理第 i 个查询 [i, r'_i] 时,pop_front 的操作会执行 while (Q.front() < i),把所有小于 i 的下标都从队首弹出。此时,如果 i 还在队列中,那么它一定没有被弹出。
为了让 c[i] 被 R[i] 弹出,我们需要在 i 仍然有效(即 l 还未超过 i)的某个时刻 k,让 r_k 扩展到 R[i]。
在我们 l_k=k 的构造下,只要 k \le i,i 就不会在第 k 步或之前被 pop_front。
所以,若要 c[i] 贡献,我们需要存在一个 k \le i,使得 r'_k \ge R[i]。由于 r' 序列是不降的,r'_k \ge R[i] 蕴含着 r'_i \ge r'_k \ge R[i]。反之,如果 r'_i \ge R[i],我们取 k=i,就满足了 k \le i 和 r'_k \ge R[i]。

因此,在这种 l_k=k 的构造下,c[i] 产生贡献的充要条件是 r'_i \ge R[i]。

这种构造足够“强大”,直觉上不会损失最优解。我们可以认为,原问题的最优解,可以被这组更简单、更结构化的 [i, r'_i] 区间选择方案达到。

转化后的问题:
寻找一个非降序列 \(r'_1, r'_2, \dots, r'_n\),满足 \(i \le r'_i \le n\) 对所有 \(i\) 成立,使得 \(\sum_{i=1}^n [r'_i \ge R[i]] \cdot c_i\) 最大化。(其中 [] 是艾弗森括号,条件成立为1,否则为0)。

三、部分分算法

在进入正解前,我们先看几个基于问题转化或原问题的部分分做法。

1. \(N \le 15\): 状态压缩 DP

这是针对原问题的小范围暴力。
设 dp[i][S] 表示处理完前 i 个区间,当前单调队列中的元素集合为 S (用一个二进制数表示) 时 sum 的最大值。
转移时,枚举第 i+1 个区间的 l_{i+1} 和 r_{i+1},根据 l_i \le l_{i+1} 和 r_i \le r_{i+1} 的限制,模拟单调队列的操作,从 dp[i][S] 转移到 dp[i+1][S']。
这个方法状态数是 \(O(n \cdot 2^n)\),转移是 \(O(n^2)\),总复杂度 \(O(n^3 \cdot 2^n)\),无法通过。但体现了解决此类问题的基本DP思想。

2. \(N \le 5000\): \(O(N^2)\) 动态规划

这个算法基于我们转化后的问题。
设 dp[i][j] 为:考虑了 \(r'_1, \dots, r'_i\) 的选择,并且钦定 \(r'_i = j\) 时,前 i 项能获得的最大贡献和。

状态: dp[i][j] (\(1 \le i \le n, i \le j \le n\))
转移:
为了计算 dp[i][j],我们需要确定 \(r'_{i-1}\) 的值。根据 r' 非降的性质, \(r'_{i-1}\) 可以是 \([i-1, j]\) 范围内的任意整数 k。
dp[i][j] 的值由两部分构成:

  1. 第 i 项的贡献:如果 \(j \ge R[i]\),则贡献 c[i],否则不贡献。
  2. 前 i-1 项的贡献:这部分的最优值是 \(\max_{k=i-1}^{j} \{dp[i-1][k]\}\)。

所以转移方程为:
\(dp[i][j] = ([j \ge R[i]] \cdot c_i) + \max_{k=i-1}^{j} dp[i-1][k]\)

优化:
注意到 \(\max_{k=i-1}^{j} dp[i-1][k]\) 是一个前缀(或区间)最大值。我们可以在计算 dp[i] 这一行时,先预处理出 dp[i-1] 行的前缀最大值。
设 max_prev[j] = max(dp[i-1][i-1], ..., dp[i-1][j])。
max_prev 数组可以 \(O(n)\) 递推得到。
然后 dp[i][j] = ([j \ge R[i]] \cdot c_i) + max_prev[j]。
这样,计算每一行 dp[i] 的时间是 \(O(n)\),总时间复杂度为 \(O(n^2)\)。

最终答案:
最终答案是所有可能情况中的最大值。因为我们可以随时停止“有效”的贡献(比如把后面的 r'_i 都设成 i,这样 R[i] 很大的话就不会有贡献),所以答案是所有 dp[i][j] 值以及 0(一个贡献都不要)中的最大值。即 \(\max(0, \max_{i,j} dp[i][j])\)。

四、正解:\(O(N \log N)\) 动态规划优化

\(O(N^2)\) 的 DP 已经为我们指明了道路。瓶颈在于每一轮从 dp[i-1] 到 dp[i] 的转移需要 \(O(n)\)。我们希望用更高效的数据结构来维护这个转移。

我们重新审视转移过程。设 \(f_i(j) = dp[i][j]\)。
\(f_i(j) = ([j \ge R[i]] \cdot c_i) + \max_{k=i-1}^{j} f_{i-1}(k)\)。

设 \(g_{i-1}(j) = \max_{k=i-1}^{j} f_{i-1}(k)\)。\(g_{i-1}\) 是一个非降函数。
那么 \(f_i(j) = g_{i-1}(j) + ([j \ge R[i]] \cdot c_i)\)。
而我们真正需要维护的是 \(g_i(j) = \max_{k=i}^{j} f_i(k)\)。
\(g_i(j) = \max_{k=i}^{j} \left( g_{i-1}(k) + ([k \ge R[i]] \cdot c_i) \right)\)

这个 \(g_i\) 的递推关系是关键:

  • 当 \(j < R[i]\) 时:
    [k >= R[i]] 始终为 0。\(g_i(j) = \max_{k=i}^{j} g_{i-1}(k)\)。因为 \(g_{i-1}\) 非降,所以 \(g_i(j) = g_{i-1}(j)\)。
  • 当 \(j \ge R[i]\) 时:
    \(g_i(j) = \max(\max_{k=i}^{R[i]-1} g_{i-1}(k), \max_{k=R[i]}^{j} (g_{i-1}(k) + c_i))\)
    \(g_i(j) = \max(g_{i-1}(R[i]-1), \quad c_i + \max_{k=R[i]}^{j} g_{i-1}(k))\)
    \(g_i(j) = \max(g_{i-1}(R[i]-1), \quad c_i + g_{i-1}(j))\)

所以,从 \(g_{i-1}\) 到 \(g_i\) 的变换是:

  1. 对于 \(j < R[i]\),值不变。
  2. 对于 \(j \ge R[i]\),值更新为 \(\max(g_{i-1}(R[i]-1), g_{i-1}(j) + c_i)\)。

我们可以用线段树来维护函数 \(g\)。线段树的下标为 \(1 \dots n\),维护 \(g(j)\) 的值。
在处理第 i 次迭代时:

  1. 查询:查询 \(v = g_{i-1}(R[i]-1)\)。这是一个单点查询。
  2. 更新:对区间 \([R[i], n]\) 进行一个特殊更新:val -> max(v, val + c[i])。

这个更新操作不是标准的线段树操作,需要分类讨论:

Case 1: \(c_i \ge 0\)
由于 \(g_{i-1}\) 非降,对于 \(j \ge R[i]\),有 \(g_{i-1}(j) \ge g_{i-1}(R[i]-1) = v\)。
因为 \(c_i \ge 0\),所以 \(g_{i-1}(j) + c_i \ge g_{i-1}(j) \ge v\)。
因此 \(\max(v, g_{i-1}(j) + c_i) = g_{i-1}(j) + c_i\)。
更新操作退化为对区间 \([R[i], n]\) 进行区间加 \(c_i\)。这是线段树的标准操作。

Case 2: \(c_i < 0\)
此时 \(g_{i-1}(j) + c_i\) 不一定大于 \(v\)。
但是,\(g_{i-1}(j) + c_i\) 随 j 是非降的。因此,存在一个分界点 p,使得:

  • 对于 \(j \in [R[i], p-1]\),\(g_{i-1}(j) + c_i < v\)。更新为 val -> v (区间赋值)。
  • 对于 \(j \in [p, n]\),\(g_{i-1}(j) + c_i \ge v\)。更新为 val -> val + c[i] (区间加)。

分界点 p 是区间 \([R[i], n]\) 中第一个满足 \(g_{i-1}(j) \ge v - c_i\) 的位置。
由于 \(g_{i-1}\) 的单调性,我们可以在线段树上二分来找到这个 p,时间复杂度为 \(O(\log n)\)。

线段树上二分找 p
从根节点开始,查询区间 \([R[i], n]\) 中第一个值 \(\ge v-c_i\) 的位置。

  • 如果当前节点代表的区间 \([L, R]\) 的最大值都小于 \(v-c_i\),则此区间内无解,返回。
  • 优先搜索左子树。如果左子树中找到了,就返回左子树的结果。
  • 如果左子树没找到,再搜索右子树。

这样,每次查找 p 只需要 \(O(\log n)\)。

总结 \(O(N \log N)\) 算法流程:

  1. \(\mathcal{O}(n)\) 预处理 R 数组。
  2. 建立一个线段树,维护 g 数组,初始时全为 0。线段树节点需要支持区间加和区间赋值的懒标记。
  3. 循环 i from 1 to n:
    a. 如果 R[i] > n,c[i] 无法贡献,跳过。
    b. 查询:在线段树上单点查询 v = g(R[i]-1)。
    c. 更新:
    - 若 \(c_i \ge 0\):对区间 \([R[i], n]\) 执行区间加 c_i。
    - 若 \(c_i < 0\):在线段树上二分找到区间 \([R[i], n]\) 中第一个值 \(\ge v-c_i\) 的位置 p。
    - 若找不到 p (所有值都小于 v-c_i),则对整个 \([R[i], n]\) 区间赋值为 v。
    - 否则,对区间 \([R[i], p-1]\) 区间赋值为 v,对区间 \([p, n]\) 区间加 c_i。
  4. 每轮迭代后,线段树根节点维护的 g(n) 就是当前的最大 sum。在所有迭代中取最大值即可。最终答案是 \(\max(0, \text{所有轮的最大g(n)})\)。

五、总结

本题从一个看似复杂的过程和约束中,通过合理的分析和简化,抽象出了一个清晰的DP模型。这是解决此类问题的关键一步。随后的 \(O(N^2)\) DP 是该模型最直接的体现,也是获得可观部分分的基础。最终,通过观察DP转移的结构,我们发现它可以用线段树这一强大的数据结构进行优化,将复杂度降至 \(O(N \log N)\),从而通过本题。整个解题过程体现了从具体问题到抽象模型,再到算法设计和优化的完整思维链条。

解题报告:P11326 「SMOI-R2」XA-Game

一、题目分析与核心思路

本题是一个关于二人博弈论的计数问题。Alice 和 Bob 在一个 01 序列上进行操作,目标是让最终剩下的唯一数字对自己有利。我们需要计算有多少种初始序列能让 Alice 获胜。

1. 博弈分析:寻找制胜关键

首先,我们分析两种操作的性质:

  • Alice (XOR): x ^ y。我们观察 1 的数量变化:

    • 0 ^ 0 = 0: 1 的数量不变。
    • 0 ^ 1 = 1: 1 的数量不变。
    • 1 ^ 1 = 0: 1 的数量减少 2。
      结论:Alice 的操作不改变序列中 1 的数量的奇偶性。
  • Bob (AND): x & y。我们观察 1 的数量变化:

    • 1 & 1 = 1: 1 的数量减少 1。
    • 1 & 0 = 0: 1 的数量减少 1。
    • 0 & 0 = 0: 1 的数量不变。
      结论:Bob 的操作,除非合并两个 0,否则都会使 1 的数量减少 1,从而改变 1 的数量的奇偶性。

游戏共有 n-1 次操作,Alice 先手。

  • Alice 操作 ceil((n-1)/2) 次。
  • Bob 操作 floor((n-1)/2) 次。

Alice 获胜的条件是最终结果为 1。这意味着在最后一步操作后,序列中 1 的数量为 1(奇数)。

我们可以从 1 的数量奇偶性入手。Alice 的操作不改变奇偶性,因此最终奇偶性完全由初始奇偶性和 Bob 的操作决定。

设序列中 1 的初始数量为 C,Bob 操作 k = floor((n-1)/2) 次。

  • 如果 Bob 在他的每一次操作中,都能通过合并 (1,1)、(1,0) 或 (0,1) 来改变 1 的数量奇偶性,那么经过 k 次操作后,1 的数量奇偶性会改变 k 次。
  • 最终 1 的数量为 1(奇数),所以最终的奇偶性是 1。
  • 倒推回来,这意味着 (C 的奇偶性 + k) mod 2 必须等于 1。即 C 的奇偶性需要与 k 的奇偶性不同。

什么时候 Bob 无法改变奇偶性?
当他被迫合并 (0,0) 时。这种情况发生在他所有的可操作选项(即所有相邻数对)都是 (0,0) 的时候。但这只可能在序列已经是全 0 时发生,此时 Alice 已经输了。
然而,如果序列中存在 (0,0) 数对,Bob 就有权选择合并它们,从而不改变 1 的数量奇偶性。这个选择权对 Bob 非常有利。他可以根据当前的奇偶性是否对他有利,来决定是“翻转奇偶性”还是“保持奇偶性”。

Alice 的最优策略就是破坏 Bob 的这种选择权,即尽量不让序列中出现 (0,0) 数对,迫使 Bob 每次都必须翻转 1 的数量奇偶性。

2. 胜利条件总结

经过上述分析,我们可以得出一个(需要被验证的)猜想,这也是本题解法的核心:

Alice 必胜的充要条件是,在 Bob 施展魔法后、游戏开始前的那个 01 序列,同时满足以下两个条件:

  1. 结构条件: 序列中 0 的所有出现位置形成一个连续的区间。换句话说,序列的形态必须是 1...10...01...1(两边的 1 和中间的 0 都可以为空)。这种结构等价于序列中不存在形如 0...1...0 的子序列。这样的结构保证了序列中不存在 (0,0) 相邻的数对,除非整个序列是 1...100...01...1。更进一步的博弈分析表明,只要满足这个 "无 0...1...0" 的结构,Alice 就能通过操作维持一个对她有利的局面,使得 Bob 无法利用 (0,0) 来控制奇偶性。

  2. 奇偶性条件: 序列中 1 的总数 C,其奇偶性必须与 Bob 的操作次数 k = floor((n-1)/2) 的奇偶性不同。即 C % 2 != k % 2,等价于 (C + k) % 2 == 1。

我们的任务,就是计算有多少个初始序列,在经过 Bob 的 m 次魔法修改后,能满足上述两个条件。

二、部分分算法

1. Subtask 1 (\(N \le 20\))

对于非常小的数据范围,我们可以直接暴力枚举。
初始序列有 n 个位置,其中 m 个位置在游戏开始前被 Bob 修改了。这意味着另外 n-m 个位置的值是我们关心的。

我们可以枚举这 n-m 个位置的所有 2^(n-m) 种 0/1 组合。对于每一种组合:

  1. 构造出完整的 n 位初始序列。
  2. 应用 Bob 的 m 次魔法,得到游戏开始时的序列。
  3. 对这个确定的序列,判断 Alice 是否必胜。判断方法可以是:
    • 直接检查它是否满足我们总结出的两个“胜利条件”。
    • 或者,通过递归搜索(minimax 算法)来模拟双方的最优操作,确定游戏结果。由于游戏状态是序列本身,且序列长度单调递减,可以用记忆化搜索优化。

这个方法的时间复杂度大致是 O(2^(n-m) * poly(n)),其中 poly(n) 是判断一次游戏结果的开销。对于 n=20,这是可行的。

2. Subtasks for \(N \le 10^6\) (e.g., test points 10-13)

当 N 变大,指数级暴力不可行。我们需要一个更高效的计数方法,动态规划(DP)是自然的选择。

我们的目标是计数满足条件的序列。DP 的状态需要能同时记录结构条件和奇偶性条件的满足情况。

  • DP 状态设计
    我们从左到右构建序列,dp[i][state] 表示考虑了前 i 个位置,有多少种方案能达到 state 状态。
    state 需要包含:

    • 结构信息:为了判断 "无 0...1...0" 的结构,我们可以用一个“阶段”变量来表示。
      • phase = 0: 到目前为止只出现了 1(或者序列为空)。
      • phase = 1: 已经出现了 0,当前处于 0 的连续区块。
      • phase = 2: 已经出现了 0 后面跟着 1,当前处于末尾的 1 区块。
      • 如果在 phase=2 时再出现 0,则违反了结构条件,计为无效方案。
    • 奇偶性信息:到目前为止 1 的数量的奇偶性 parity (0 或 1)。

    所以,DP 状态可以设为 dp[i][phase][parity]。

  • DP 转移
    dp[i][...][...] 的值由 dp[i-1][...][...] 转移而来,取决于第 i 位的数字是 0 还是 1。

    • 放 0:
      • (phase=0, par) -> (phase=1, par)
      • (phase=1, par) -> (phase=1, par)
      • (phase=2, par) -> 无效
    • 放 1:
      • (phase=0, par) -> (phase=0, par^1)
      • (phase=1, par) -> (phase=2, par^1)
      • (phase=2, par) -> (phase=2, par^1)
  • 处理 m 个限制
    Bob 的 m 个魔法操作 (a_i, v_i) 将序列分成了 m+1 个不受限制的连续段。我们可以对每一段分别进行 DP。
    例如,从第 a_{i-1}+1 位到 a_i-1 位,是一个长度为 a_i - a_{i-1} - 1 的自由段。我们可以用 DP 在这个段上进行转移。然后处理固定的第 a_i 位,再继续处理下一段。
    总复杂度为 O(N)。这足以通过 N \le 10^6 的点。

三、正解:矩阵快速幂优化 DP

当 N 达到 10^{15} 时,O(N) 的 DP 也不够快了。注意到 DP 的转移是线性的,且状态数很少(3 * 2 = 6 种有效状态),这是使用矩阵快速幂加速的典型场景。

1. DP 的矩阵化

我们可以将 DP 状态 (phase, parity) 编码成一个整数,例如 k = phase * 2 + parity。
dp[i] 可以看作一个大小为 6 的向量 F_i,F_i[k] 存储 dp[i][k] 的值。

DP 转移 F_i = T(F_{i-1}) 可以表示为矩阵乘法 F_i = M \cdot F_{i-1}。M 是一个 6x6 的转移矩阵。
由于在自由段,每一位可以是 0 或 1,我们可以定义两个转移矩阵:

  • M_0:在末尾添加 0 的转移矩阵。
  • M_1:在末尾添加 1 的转移矩阵。

自由段的转移矩阵 M_{any} = M_0 + M_1。
要计算长度为 L 的自由段的转移,等价于计算 (M_{any})^L。我们可以用矩阵快速幂在 O(V^3 log L) 时间内完成,V=6 是矩阵维度。

2. 整体算法流程

  1. 确定目标奇偶性:计算 W = (floor((n-1)/2) % 2 == 0) ? 1 : 0。这是我们希望最终 1 的数量的奇偶性。

  2. 构建转移矩阵:根据 DP 转移规则,构建 M_0 和 M_1,然后得到 M_{any} = M_0 + M_1。

  3. 分段处理:
    a. 将 Bob 的 m 个修改点 a_1, ..., a_m 看作分界点。补充 a_0=0 和 a_{m+1}=n+1。
    b. 初始化状态向量 F。一个长度为 0 的序列,可以看作处于 (phase=0, parity=0) 状态。所以 F 初始时在对应位置为 1,其余为 0。
    c. 遍历 i from 1 to m+1:
    i. 处理自由段 (a_{i-1}, a_i):长度为 L = a_i - a_{i-1} - 1。如果 L>0,用矩阵快速幂计算 M_{any}^L,然后更新 F := M_{any}^L \cdot F。
    ii. 如果 i \le m,处理固定点 a_i:其值必须为 v_i。更新 F := M_{v_i} \cdot F。

  4. 计算结果:
    a. 经过上述所有步骤,向量 F 中存储了所有长度为 n、满足结构条件和 m 个固定点约束的序列,按末尾状态分类的方案数。
    b. 我们需要的最终 1 的数量奇偶性为 W。将 F 中所有 parity 为 W 的状态 (F[0*2+W], F[1*2+W], F[2*2+W]) 的方案数相加,得到 K。
    c. 关键点:我们计算的是满足条件的最终序列数量 K。题目问的是初始序列数量。对于 Bob 修改的 m 个位置,它们在初始序列中可以是 0 或 1(共 2^m 种可能),都会被覆盖成 v_i。因此,每个合法的最终序列对应 2^m 个初始序列。
    d. 最终答案是 (K \cdot 2^m) \pmod{10^9+7}。

3. “光速幂”优化

当 T 和 \sum m 很大时,每次都用 log n 的矩阵快速幂可能超时。我们可以预处理矩阵的幂来加速。这类似于整数的快速幂可以用查表优化。

  • 预处理:选择一个块大小 B (例如 10^5)。
    • 预计算 M_{any}^1, ..., M_{any}^B。
    • 预计算 (M_{any}^B)^1, ..., (M_{any}^B)^B。
  • 查询:计算 M_{any}^L 时,将 L 表示为 qB+r。M_{any}^L = (M_{any}^B)^q \cdot M_{any}^r。这样可以通过两次查表和一次矩阵乘法得到结果。
  • 题解代码中的实现:代码中使用基数为 P=100000 的方法,预处理了 M_{any}^j, (M_{any}^P)^j, (M_{any}^{P^2})^j,可以将任意 L (在 n 的范围内) 的幂次计算优化到几次矩阵-向量乘法,总复杂度为 O(\text{预处理} + (\sum m) \cdot V^2)。
posted @ 2025-07-24 12:06  surprise_ying  阅读(142)  评论(0)    收藏  举报