NOIP 2025 模拟赛1
P11323 【MX-S7-T1】「SMOI-R2」Happy Card 解题报告
题意分析
这道题的目标是:用最少的出牌次数,打完手中所有的牌。
我们有 \(n\) 种牌,每种牌的数量是 \(v_i\)。首先,我们需要理解出牌规则和它们的效率:
- 单牌:1 张牌,1 次出牌。效率最低。
- 对子:2 张同种牌,1 次出牌。
- 三带一:3 张同种牌 + 1 张任意牌,共 4 张牌,1 次出牌。
- 炸:4 张同种牌,1 次出牌。
观察发现,“三带一”和“炸”都是用 4 张牌,只出 1 次,是效率最高的出牌方式。为了让总出牌次数最少,我们的核心策略应该是尽可能多地凑出 4 张牌的组合。
题解中给出了一个关键提示:“对于‘三带一’和‘王炸’,可以合并成一种出牌方式,即三张同种的牌带上一个牌。” 这句话非常重要,它简化了问题。我们可以认为,“炸”(X,X,X,X) 也是一种特殊的“三带一”,即用三张 X 带一张 X。这样,我们就不需要区分“炸”和“三带一”了,统一目标为凑出尽可能多的 “三带一” 组合。
一个“三带一”组合由一个“三张”部分(如 X,X,X)和一个“带牌”部分(任意一张牌 Y)组成。所以,我们的策略可以分解为:
- 从所有牌中,尽可能多地凑出“三张”的组合。
- 用剩下的零散牌作为“带牌”,来满足这些“三张”组合,形成“三带一”。
- 处理那些无法形成“三带一”的牌。
核心思路与算法设计
根据上面的分析,我们可以设计一个贪心算法。
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 张。这不如用单张划算,因为它虽然也完成了一个“三带一”,但留下了一个“烂摊子”(一个需要再出一次的单牌)。
因此,我们的贪心策略是:
- 优先用
s1个单张牌堆去匹配sall个“三张”组合。
能匹配的数量是min(sall, s1)。 - 然后,用
s2个对子牌堆去匹配剩余的“三张”组合。
每个对子可以提供两张“带牌”。我们把这s2个牌堆看成s2 * 2张单独的牌,去匹配剩余的sall个“三张”组合。 - 最后,如果还有“三张”组合没被满足,它们只能内部消化。
比如我们还有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代表的剩余牌堆,如果能被用作“带牌”,那么它们就被“吸收”了,不额外增加出牌次数。- 如果一个牌堆没被完全吸收,它就需要独立出牌,增加最终的次数。
所以,算法流程如下:
- 初始化
ans = 0。遍历所有牌种,累计sall,s1,s2。记下初始的sall值为la。 - 用
s1牌堆去满足sall个“三张”。match = min(sall, s1)。sall和s1都减去match。 - 用
s2牌堆里的牌去满足sall。s2个牌堆共有s2 * 2张牌可作“带牌”。match = min(sall, s2 * 2)。sall减match。s2的牌堆数减少match / 2。如果match是奇数,一个s2牌堆会剩下一张,但题解代码简化处理了这一点(s2 -= tmp / 2),事实证明这个简化是有效的。 - 最终答案就是
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。然后进行贪心匹配:
- 用
c1去匹配c3。 - 用
c2去匹配c3。 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)\),我们要计算的其实是:
观察这个式子,对于一个固定的询问 \((x, y)\), \(c_x\) 和 \(c_y\) 是不变的。我们可以把它们提到 max 外面:
这样,问题的核心就变成了:对于给定的 \(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)来求出两点间的距离。
算法流程:
- 对于每个询问 \((x, y)\):
- 初始化最大利润
max_profit为一个极小值。 - 遍历所有城市 \(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)。 - 输出
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\))
算法一的瓶颈在于重复计算距离。我们可以先花时间预处理出所有点对之间的距离。
算法流程:
- 预处理阶段:
a. 创建一个二维数组dist[n+1][n+1]。
b. 遍历所有城市 \(i\) 从 \(1\) 到 \(n\):
i. 从 \(i\) 出发,做一次 DFS/BFS,计算出 \(i\) 到所有其他城市的距离,并存入dist[i][j]。 - 查询阶段:
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\) 的最短距离。
代入原式:
dist(x, y) 对于固定的 \((x, y)\) 是常量,可以提出来:
令 \(u\) 为路径 \(x \to y\) 上离 \(z\) 最近的点,则 dist(z, path(x,y)) = dist(z, u)。
所以最大化部分变成了:
我们可以把 \(z\) 按照它们各自对应的 \(u\) 分组:
这一步的转换非常精妙,它证明了,我们只需要考虑那些把 \(z\) 选在 "挂在" \(x-y\) 路径上的各个分支里的情况。
但还有更简单的看法。我们可以证明上面这个复杂的式子等价于一个更简单的式子:
为什么呢?这步的证明略有些挑战,但直观上可以理解为:对于路径上的任意一点 \(u\),我们都去寻找全局最好的 \(v\) 来与它配对,这一定不会比只在特定分支里找要差。而最终全局最优解一定能被某个路径上的点 \(u\) 以这种方式找到。
所以,我们定义一个新数组 \(D_u = \max_{v=1 \dots n} \{ c_v - 2 \cdot \text{dist}(u, v) \}\)。
最终的答案公式就变成了:
这个公式是正解的基础。问题被分解为三个子问题:
- 对所有 \(u\),预计算出 \(D_u\)。
- 对于查询 \((x, y)\),快速计算
dist(x, y)。 - 对于查询 \((x, y)\),快速查询 \(x \to y\) 路径上所有点的 \(D_u\) 的最大值。
正解:换根DP + 树上倍增 (\(100\)分)
思路对应所有测试点
核心目标与策略
首先,我们要记住我们的核心目标是为每一个节点 u,计算出这个值:
也就是对每个 u,都要在全图找一个最优的 v。
直接对每个 u 都遍历全图太慢了。换根DP的巧妙之处在于,当我们为 u 计算 D[u] 时,把 v 的可能位置分成两类:
v在以u为根的子树里(包括u自己)。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) 这个“效益”。
你有以下几个选择:
- 就选当前城市
u(v = u)。- 此时
dist(u, u) = 0,所以效益是c[u]。这是你的一个保底选项。
- 此时
- 从下属
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]。
状态转移方程
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 能提供给你的“外部信息”来自哪里呢?
- 来自
p的上级(也就是你爷爷辈):这部分信息就是up[p],它代表了p从它子树之外能获得的最优值。 - 来自你的“兄弟”部门(
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 子树信息的最大值。
那么,u 从子树外部能获得的最大效益就是:
最终合并
经过这两次DFS,对于任何一个节点 u:
- 我们有了它从子树内部获得的最优值
down1[u]。 - 我们有了它从子树外部获得的最优值
up[u]。
v 要么在子树内,要么在子树外,两者包含了所有可能性。所以,全局的最优值就是这两者的最大值:
这样,我们就用 \(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 进行选择,状态空间巨大。我们需要简化决策。
区间的选择有两个限制:
- \(l_1 \le l_2 \le \dots \le l_n\)
- \(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] 的值由两部分构成:
- 第
i项的贡献:如果 \(j \ge R[i]\),则贡献c[i],否则不贡献。 - 前
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\) 的变换是:
- 对于 \(j < R[i]\),值不变。
- 对于 \(j \ge R[i]\),值更新为 \(\max(g_{i-1}(R[i]-1), g_{i-1}(j) + c_i)\)。
我们可以用线段树来维护函数 \(g\)。线段树的下标为 \(1 \dots n\),维护 \(g(j)\) 的值。
在处理第 i 次迭代时:
- 查询:查询 \(v = g_{i-1}(R[i]-1)\)。这是一个单点查询。
- 更新:对区间 \([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)\) 算法流程:
- \(\mathcal{O}(n)\) 预处理
R数组。 - 建立一个线段树,维护
g数组,初始时全为 0。线段树节点需要支持区间加和区间赋值的懒标记。 - 循环
ifrom 1 ton:
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。 - 每轮迭代后,线段树根节点维护的
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 序列,同时满足以下两个条件:
-
结构条件: 序列中
0的所有出现位置形成一个连续的区间。换句话说,序列的形态必须是1...10...01...1(两边的1和中间的0都可以为空)。这种结构等价于序列中不存在形如0...1...0的子序列。这样的结构保证了序列中不存在(0,0)相邻的数对,除非整个序列是1...100...01...1。更进一步的博弈分析表明,只要满足这个 "无0...1...0" 的结构,Alice 就能通过操作维持一个对她有利的局面,使得 Bob 无法利用(0,0)来控制奇偶性。 -
奇偶性条件: 序列中
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 组合。对于每一种组合:
- 构造出完整的
n位初始序列。 - 应用 Bob 的
m次魔法,得到游戏开始时的序列。 - 对这个确定的序列,判断 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)
- 放 0:
-
处理
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. 整体算法流程
-
确定目标奇偶性:计算
W = (floor((n-1)/2) % 2 == 0) ? 1 : 0。这是我们希望最终1的数量的奇偶性。 -
构建转移矩阵:根据 DP 转移规则,构建
M_0和M_1,然后得到M_{any} = M_0 + M_1。 -
分段处理:
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. 遍历ifrom 1 tom+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。 -
计算结果:
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)。
浙公网安备 33010602011771号