文章目录
- 区域赛分类题解
-
- 图论区域赛题题解
-
- 2025网络亚洲预选赛---Dijkstra算法
- 2021年上海站第H题---Kruskal重构树
- 第47届国际大学生编程设计竞赛西安站B---网络流
- 第49届国际大学生编程设计竞赛成都站K---网络流
- [2024年ICPC上海站E---Tarjan 算法](#2024年ICPC上海站E---Tarjan 算法)
- DP区域赛题
- 字符串类算法区域赛题
- 数据结构类区域赛题
- 数论区域赛题
- 博弈论区域赛题
- 贪心区域赛题
区域赛分类题解
图论区域赛题题解
2025网络亚洲预选赛---Dijkstra算法
-
题目链接:2025网络亚洲预选赛
-
题目重现和思路讲解:
text【一、题目描述】 给定一张 N 个点、M 条边的无向图,每条边上有一个重量 w 的物品。 背包容量 V。小 S 从起点出发,每经过一条边,就必须把该边上的物品 放进背包。若当前背包剩余容量装不下 w,就换一个新背包(旧背包丢弃)。 对于每个起点 i = 1..N,求走到指定终点 T 所需的最少背包数量。 无解输出 -1。 1 ≤ N, M, V ≤ 1e5,1 ≤ w ≤ V,1 ≤ T ≤ N。 【二、核心观察:路径可逆,反向建图】 从 i 走到 T 的路径,反过来就是从 T 走到 i 的路径, 且消耗的背包数量相同(因为每经过一条边都要装物品,与方向无关)。 ★ 所以问题转化为:以 T 为起点,求到每个点 i 的最少背包数。 【三、状态设计】 普通 Dijkstra 只维护"距离",但这题的状态有两个维度: · bags:已经用了多少个背包 · rem :当前背包的剩余容量 状态 = (bags, rem, u),表示在点 u,已经用了 bags 个背包, 当前背包还剩 rem 容量。 【四、优先队列排序规则】 1. 先按 bags 从小到大 ------ 背包数越少越好 2. bags 相同时,按 rem 从大到小 ------ 剩余容量越大越有利 ★ 为什么不是单纯的"背包数最小"? 同一节点可能有多种状态:背包数少但剩余容量小,或者背包数多但容量大。 后者虽然背包多,但可能因为容量充足而走得更远, 所以需要把两种状态都保留,交给优先队列综合排序。 【五、初始状态(关键)】 站在 T 上时,其实已经"开了 1 个背包": · 因为你一旦往外走,那条边的物品必须装进某个背包, 这个背包从一开始就存在。 · 所以初始状态是 dist[T] = 1, rem[T] = V。 ★ 如果初始设成 0,整条路径上所有答案都会少 1,直接 WA。 【六、状态转移】 从状态 (bags, rem, u) 出发,看每条边 (u, v, w): ① rem >= w:当前背包够装 nd = bags, nr = rem - w ② rem < w :当前背包装不下,换新背包 · 若 w > V:单条边的物品都超过背包容量,这条边走不通,跳过 · 否则 nd = bags + 1, nr = V - w ★ 每经过一条边,物品都必须装进背包,没有"不装"的选择。 【七、剪枝与更新】 维护两个数组: · dist[u]:到达 u 的最少背包数 · best[u]:在 dist[u] 个背包下,到达 u 时的最大剩余容量 剪枝(弹出堆顶时): if (bags > dist[u]) continue; if (bags == dist[u] && rem < best[u]) continue; ------ 这个状态不如已知最优,跳过。 更新(松弛邻居 v 时): if (nd < dist[v] || (nd == dist[v] && nr > best[v])) dist[v] = nd; best[v] = nr; 入队 ------ 背包更少,或同背包数但剩余容量更大,才更新。 【八、算法流程】 1. 读入 N、M、V、T 以及所有边。 2. 建图(无向图,双向加边)。 3. 初始化:dist[1..N] = INF,best[1..N] = -1。 dist[T] = 1, best[T] = V,把 (1, V, T) 压入优先队列。 4. 循环弹出堆顶,按规则剪枝、转移、更新邻居。 5. 输出:dist[i] 为 INF 则输出 -1,否则输出 dist[i]。 【九、复杂度】 每个节点的状态可能被更新多次,但配合剪枝,实际入队次数有限。 总复杂度约为 O((N + M) log(N + M)),可过 1e5。 【十、样例理解】 N=8, M=10, V=7, T=4。 从 1 到 4: 路径 1→5→6→7→4: 1→5 (w=2),装 2,剩 5 5→6 (w=5),装 5,剩 0 6→7 (w=3),装不下,换新背包,剩 4 7→4 (w=4),装 4,剩 0 共用 2 个背包 → 输出 2 从 4 到 4:本身就在 T,初始就是 1 个背包 → 输出 1 从 8 到 4:无边相连,无法到达 → 输出 -1 样例输出:2 2 1 1 2 1 1 -1 ✓ 【十一、总结】 路径可逆 → 反向建图,从 T 出发 → 状态 (背包数, 剩余容量, 节点) → 优先队列:背包数少优先,容量大优先 → 初始状态 T 已有 1 个背包(关键!) → 转移时:够装就装,不够就换新背包 → dist 记录最少背包,best 记录同背包数下的最大剩余容量 → 答案 = dist[1..N],无解为 -1所以基于以上思路代码如下:
cpp#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; const int MAXM = 200005; const int INF = 0x3f3f3f3f; int head[MAXN], nxt[MAXM], to[MAXM], w[MAXM], cnt; int n, m, V, T; int dist_[MAXN]; // dist_[u] = 从 T 到 u 的最少背包数 int best[MAXN]; // best[u] = 在 dist_[u] 个背包下,到达 u 时的最大剩余容量 struct State { int bags, rem, u; bool operator<(const State& o) const { if (bags != o.bags) return bags > o.bags; // 背包少优先 return rem < o.rem; // 剩余容量大优先 } }; void addEdge(int u, int v, int ww) { nxt[++cnt] = head[u]; to[cnt] = v; w[cnt] = ww; head[u] = cnt; } void dijkstra() { fill(dist_ + 1, dist_ + n + 1, INF); fill(best + 1, best + n + 1, -1); // 关键:起点 T 已经有 1 个背包,剩余容量 V dist_[T] = 1; best[T] = V; priority_queue<State> pq; pq.push({1, V, T}); while (!pq.empty()) { auto [bags, rem, u] = pq.top(); pq.pop(); // 剪枝:不是更优状态就跳过 if (bags > dist_[u]) continue; if (bags == dist_[u] && rem < best[u]) continue; for (int e = head[u]; e; e = nxt[e]) { int v = to[e]; int ww = w[e]; int nd, nr; if (rem >= ww) { // 当前背包够装 nd = bags; nr = rem - ww; } else { // 换新背包 if (ww > V) continue; // 单条边都装不下,走不通 nd = bags + 1; nr = V - ww; } // 更新 v:背包更少,或同背包数但剩余容量更大 if (nd < dist_[v] || (nd == dist_[v] && nr > best[v])) { dist_[v] = nd; best[v] = nr; pq.push({nd, nr, v}); } } } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m >> V >> T; for (int i = 0; i < m; i++) { int x, y, ww; cin >> x >> y >> ww; addEdge(x, y, ww); addEdge(y, x, ww); } dijkstra(); for (int i = 1; i <= n; i++) { if (dist_[i] == INF) cout << -1; else cout << dist_[i]; if (i < n) cout << " "; } cout << "\n"; return 0; }
2021年上海站第H题---Kruskal重构树
2021 ICPC 上海站 H 题 "Life is a Game" :2021上海站第H题
思路解析:
text
【一、题目描述】
给定一张 N 个点、M 条边的无向连通图。
每个点 i 有一个点权 a[i](到达该点即可获得,只能获得一次)。
每条边有边权 w。
有 Q 次询问,每次给定起点 x 和初始能量 k:
从 x 出发,初始能量为 k;
每到达一个新点,能量加上该点的点权(不重复加);
只有当前能量 ≥ 边权时,才能通过这条边;
问最终能量最大是多少。
1 ≤ N, M, Q ≤ 1e5。
【二、核心观察:走过的边构成 MST 的一部分】
从 x 出发能走的边,一定是"边权 ≤ 当前能量"的边。
如果某条边权为 w 的边能走,那么所有边权 < w 的边也能走。
所以能到达的点集,一定可以通过**最小生成树**上的边到达。
★ 关键:能走通的两个点之间,路径一定在 MST 上。
于是问题转化为:在 MST 上,从 x 出发,边权限制下能扩展到哪些点。
【三、Kruskal 重构树】
在普通 Kruskal 合并两个集合时:
不直接连原来的边,而是新建一个节点 t,权值为当前边权 w,
把两个集合的代表作为 t 的左右儿子。
得到的二叉树满足:
· 叶子 = 原图的点,权值为点权。
· 内部节点 = MST 的边,权值为边权,且从叶子往上单调不减。
★ 从叶子 x 往上跳到某个祖先 u,意味着:
从 x 出发,只要初始能量足够跨过 u 的权值,
就能到达 u 子树中的所有叶子,并获得它们的点权和。
【四、建树时同步维护 sum 与 fa】
在 Kruskal 建树的过程中,同时维护两样东西:
· sum[u] = u 子树内所有叶子的点权之和
· fa[u] = u 在重构树上的父亲
具体做法:
· 叶子节点初始化 sum[i] = a[i]。
· 每次合并两个集合 fx、fy 时,新建节点 t:
nodeKey[t] = w
sum[t] = sum[fx] + sum[fy]
fa[fx] = fa[fy] = t
★ 建树完成时,sum 和 fa 就已经全部正确,
不需要再对重构树做一次 DFS。
【五、倍增预处理】
因为内部节点编号一定大于它的两个儿子,
所以直接按节点编号从小到大扫一遍,就能线性建好倍增表:
· up[u][p]:u 向上跳 2^p 步的祖先
· need[u][p]:从 u 出发,初始能量 k 至少多大才能向上跳 2^p 步
need[u][0] = nodeKey[fa[u]] − sum[u]
need[u][p] = max( need[u][p−1], need[mid][p−1] )
其中 mid = up[u][p−1]
★ 为什么直接取 max,不用修正 sum:
need[mid][p−1] 是基于 mid 子树算的,
但从 u 到 mid 的过程中,吃到的点权正好是 sum[mid]
(因为 mid 子树包含 u 子树),
两边基准一致,sum 自动抵消,直接取 max 即可。
【六、单次询问回答】
对询问 (x, k):
1. 令 cur = x。
2. 从大到小枚举 p:
若 up[cur][p] 存在,且 need[cur][p] ≤ k,
则 cur = up[cur][p]。
3. 最终答案 = k + sum[cur]。
★ 即:从叶子 x 尽量往上跳,跳到能到达的最远祖先,
该祖先子树内的点权和全部能吃下。
【七、算法流程】
1. 读入 N、M、Q 以及点权、边。
2. 边按权值从小到大排序,Kruskal 构建重构树:
每次合并新建节点 t,权值为边权 w,儿子为两个集合代表;
同步维护 sum[t] = sum[左] + sum[右],fa[左] = fa[右] = t。
3. 按节点编号从小到大线性扫一遍,预处理 up 和 need 倍增表。
4. 对每个询问 (x, k):
从 x 向上倍增跳,直到不能再跳;
输出 k + sum[cur]。
5. 结束。
【八、复杂度】
建重构树:O(M log M)
倍增预处理:O(N log N)
每次询问:O(log N)
总复杂度:O((N + M) log M + Q log N),可过 1e5。
【九、样例理解】
一条链 1 --(w=5)-- 2 --(w=10)-- 3,点权都是 1。
重构树:
t2(w=10)
/ \
t1(w=5) 3
/ \
1 2
建树时同步得到:
sum[1]=1, sum[2]=1, sum[3]=1
sum[t1]=2, sum[t2]=3
询问 (1, k=5):
当前能量 = 5 + 1 = 6 ≥ 5,能跨到 t1;
想跨到 t2 需要 k + sum[t1] ≥ 10,
5 + 2 = 7 < 10,跨不过去;
只能到达 {1, 2},答案 = 5 + 2 = 7。
询问 (1, k=8):
8 + 2 = 10 ≥ 10,能跨到 t2;
最终到达 {1, 2, 3},答案 = 8 + 3 = 11。
【十、总结】
从 x 出发能走的边 = MST 上边权受能量限制的边
→ 建 Kruskal 重构树(内部节点权值 = 边权)
→ 建树时同步维护 sum 和 fa,无需 DFS
→ 从叶子往上跳 = 能量够就扩展
→ 倍增预处理"跳 2^p 步所需最小初始能量"
→ 每次询问 O(log N) 找到最远祖先
→ 答案 = k + 该祖先子树的点权和
代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class kruskal {
public:
int run() {
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> edge[i].u >> edge[i].v >> edge[i].w;
// 构建 Kruskal 重构树
kruskalRebuild();
// 预处理倍增表
buildNeed();
// 处理每个询问
while (q--) {
int x;
long long k;
cin >> x >> k;
int cur = x;
// 从大到小枚举 p,尽量往上跳
// 条件:祖先存在 且 初始能量 k >= need[cur][p]
for (int p = MAXP - 1; p >= 0; p--) {
if (stjump[cur][p] != 0 && need[cur][p] <= k) {
cur = stjump[cur][p];
}
}
// 最终答案 = 初始能量 k + 当前子树点权和
cout << k + sum[cur] << "\n";
}
return 0;
}
private:
// 边结构体:u、v 为端点,w 为边权
struct Edge {
int u, v, w;
};
// 按边权从小到大排序
bool cmp(Edge x, Edge y) {
return x.w < y.w;
}
static constexpr int MAXK = 200005; // 重构树最大节点数(2n-1,n 最大 1e5)
static constexpr int MAXM = 300005; // 最大边数
static constexpr int MAXP = 20; // 倍增跳表最大层数(2^20 > 2e5)
int n, m, q;
Edge edge[MAXM];
int a[MAXK]; // 每个城市的点权(叶子节点的值)
int father[MAXK]; // 并查集父指针,用于 Kruskal 合并
int fa[MAXK]; // 重构树的父指针
long long sum[MAXK]; // 每个节点子树的点权和
long long nodeKey[MAXK]; // 节点权值:叶子为 0,内部节点为对应的边权
int cntu; // 重构树节点总数
int stjump[MAXK][MAXP]; // 倍增祖先:stjump[u][p] = u 向上跳 2^p 步的祖先
long long need[MAXK][MAXP];// need[u][p]:从 u 出发,初始能量 k 至少多大才能向上跳 2^p 步
// 并查集查找(带路径压缩)
int find(int i) {
if (i != father[i]) father[i] = find(father[i]);
return father[i];
}
// Kruskal 重构树构建
void kruskalRebuild() {
// 初始化并查集:重构树最多 2n-1 个节点
for (int i = 1; i <= 2 * n; i++) father[i] = i;
// 边按权值从小到大排序
sort(edge + 1, edge + m + 1, [this](auto& __x, auto& __y) { return cmp(__x, __y); } );
cntu = n; // 内部节点从 n+1 开始编号
// 叶子节点的初始值:sum = 点权,nodeKey = 0
for (int i = 1; i <= n; i++) {
sum[i] = a[i];
nodeKey[i] = 0;
}
// 依次处理每条边
for (int i = 1; i <= m; i++) {
int fx = find(edge[i].u);
int fy = find(edge[i].v);
if (fx != fy) {
// 两个集合不在同一个连通块,可以合并
++cntu;
father[fx] = father[fy] = cntu; // 并查集:两集合合并到新节点
father[cntu] = cntu;
nodeKey[cntu] = edge[i].w; // 新节点的权值 = 当前边权
sum[cntu] = sum[fx] + sum[fy]; // 新子树点权和 = 两子树之和
fa[fx] = fa[fy] = cntu; // 重构树:新节点作为 fx、fy 的父亲
}
// 若 fx == fy 则舍弃这条边(成环)
}
}
// 预处理 need 数组(倍增)
void buildNeed() {
// p = 0:直接跨到父节点
for (int v = 1; v <= cntu; v++) {
stjump[v][0] = fa[v];
if (fa[v] == 0) {
need[v][0] = 0; // 根节点,没有父亲,不需要能量
} else {
// 到达 v 时已获得 sum[v] 点权,绝对能量 = k + sum[v]
// 要求 k + sum[v] >= nodeKey[父],即 k >= nodeKey[父] - sum[v]
need[v][0] = nodeKey[fa[v]] - sum[v];
}
}
// 倍增合并:从 v 跳 2^(p-1) 步到 mid,再跳 2^(p-1) 步
// need[v][p] 表示从 v 出发跳 2^p 步所需的初始能量
// 关键:从 v 出发到 mid 的过程,吃到的点权正好等于 sum[mid]
// (因为 mid 子树包含 v 子树)
// 所以 need[mid][p-1] 本身就是基于相同基准算出来的
// 直接取 max 即可,不需要修正 sum
for (int p = 1; p < MAXP; p++) {
for (int v = 1; v <= cntu; v++) {
int mid = stjump[v][p - 1];
if (mid == 0) {
// mid 不存在,不能跳那么远
stjump[v][p] = 0;
need[v][p] = need[v][p - 1];
} else {
stjump[v][p] = stjump[mid][p - 1];
need[v][p] = max(need[v][p - 1], need[mid][p - 1]);
}
}
}
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
kruskal solver;
return solver.run();
}
第47届国际大学生编程设计竞赛西安站B---网络流
- 题目链接:第47届国际大学生编程设计竞赛西安站B
- 题目描述及思路:
text
Cells Coloring(2022 ICPC 西安区域赛 B / CF Gym 104077 B)
【题目描述】
给定一个 n × m 的网格,其中有一些障碍('*'),其余是空格('.')。
你需要选择一个非负整数 k,然后用 k+1 种颜色 0, 1, 2, ..., k 给所有空格染色。
要求:同一行或同一列中,不能有两个格子被染成相同的非零颜色(颜色 0 没有限制)。
给定两个非负整数 c 和 d。
对于一个染色方案,设 z 为染成颜色 0 的格子数。
定义该方案的代价为 c * k + d * z。
求最小的代价。
【输入】
第一行:n, m (1 ≤ n, m ≤ 250), c, d (0 ≤ c, d ≤ 10^9)
接下来 n 行,每行一个长度为 m 的字符串,'.' 表示空格,'*' 表示障碍。
【输出】
输出一行,一个整数,表示最小代价。
【样例1】
输入:
3 4 2 1
.***
*..*
**..
输出:
4
【样例2】
输入:
3 4 1 2
.***
*..*
**..
输出:
2
【思路解说】
【一、问题转化:非零颜色 = 匹配】
把网格的每一行看作二分图左侧的节点,每一列看作右侧的节点。
如果格子 (i, j) 是空格,则在行 i 和列 j 之间连一条边。
给一个空格染上非零颜色,等价于在这条边上"使用"一次这个颜色。
题目要求同一行或同一列不能有两个格子被染成相同的非零颜色,
这正好对应了**匹配**的定义:一种非零颜色就是一个匹配,它选择了若干条互不共端点的边。
所以,用 k 种非零颜色,等价于把图中的边分解成 k 个匹配。
【二、König 边染色定理】
根据 König 边染色定理,一个二分图可以用 Δ 种颜色进行边染色,
其中 Δ 是图中所有顶点度数的最大值(即最大的行点数或列点数)。
对于固定的 k,能覆盖的最多空格数 W(k) 等于:
- 在二分图中,选择尽可能多的边,使得每个顶点的度数不超过 k。
- 这等价于一个**b-匹配**问题(每个顶点容量为 k 的最大匹配)。
所以,固定 k 时,非零颜色最多能覆盖 W(k) 个空格,
剩下的 z = E - W(k) 个空格必须染成颜色 0,其中 E 是总空格数。
【三、代价函数与凸性】
代价函数为:
cost(k) = c * k + d * (E - W(k))
我们需要在 k = 0, 1, ..., Δ 中找到使 cost(k) 最小的 k。
**关键性质**:W(k) 是一个**凹函数**(因为最大流的值关于容量是凹的),
所以 cost(k) 是一个**凸函数**。因此,我们可以**二分**找到最优的 k。
具体地,我们比较 cost(k+1) - cost(k):
cost(k+1) - cost(k) = c - d * (W(k+1) - W(k))
当这个差值 >= 0 时,说明增加 k 不再划算,最优 k 在左侧;
否则最优 k 在右侧。
所以,二分找到最小的 k 使得 c - d * (W(k+1) - W(k)) >= 0,
这个 k 就是最优的 k。
【四、W(k) 的计算:最大流】
对于固定的 k,W(k) 可以通过最大流计算:
- 源点 S 连每一行,容量为 k
- 每一列连汇点 T,容量为 k
- 对于每个空格 (i, j),行 i 连列 j,容量为 1
- 跑最大流,得到的流量就是 W(k)
【五、算法流程】
1) 读入网格,统计总空格数 E,以及每行、每列的空格数(度数)。
2) 计算 Δ = max(最大行度数, 最大列度数)。
3) 如果 Δ == 0,说明没有空格,直接输出 0。
4) 二分查找最优的 k:
- lo = 0, hi = Δ - 1
- 每次计算 mid,比较 cost(mid+1) - cost(mid) 的符号
- 根据符号调整二分区间
5) 计算最终答案 cost(k0) = c * k0 + d * (E - W(k0))。
【六、复杂度】
- 每次最大流:O(E√V),其中 V = n + m + 2,E = 空格数
- 二分次数:O(log Δ)
- 总复杂度:O(E√V log Δ),可以过。
【七、注意事项】
- 非零颜色数 k 的范围是 0 到 Δ。
- W(k) 是凹函数,所以 cost(k) 是凸函数,可以二分。
- 二分时比较的是 cost(k+1) - cost(k) 的符号。
- 最大流用 Dinic 实现,注意容量为 k。
- 答案可能很大,要用 long long。
- 如果 Δ == 0,直接输出 0。
- 基于以上思路,代码如下:
cpp
// 非零颜色类=匹配;k 个匹配的并 <=> 子图行/列度数<=k (König 边染色)。
// 固定 k 时非零色最多 W(k) = 行/列度数<=k 的最大边数 = 二分图 b-匹配(最大流)。
// 答案 = min_{k=0..Δ} c*k + d*(E - W(k));W(k) 凹 => cost(k) 凸 => 二分最优 k。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// Dinic 最大流模板
struct Dinic {
struct E {
int to, rev; // to: 终点; rev: 反向边在 g[to] 中的下标
ll cap; // 剩余容量
};
vector<vector<E>> g; // 邻接表
vector<int> level; // 分层图:到源点的距离
vector<int> it; // 当前弧优化
// 初始化,n 为节点数
void init(int n) {
g.assign(n, {});
level.resize(n);
it.resize(n);
}
// 添加一条从 u 到 v,容量为 cap 的有向边
void add(int u, int v, ll cap) {
g[u].push_back({v, (int)g[v].size(), cap});
g[v].push_back({u, (int)g[u].size() - 1, 0});
}
// BFS 建分层图
bool bfs(int s, int t) {
fill(level.begin(), level.end(), -1);
queue<int> q;
level[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (auto& e : g[u]) {
if (e.cap > 0 && level[e.to] < 0) {
level[e.to] = level[u] + 1;
q.push(e.to);
}
}
}
return level[t] >= 0;
}
// DFS 沿分层图找增广路(当前弧优化)
ll dfs(int u, int t, ll f) {
if (u == t) return f;
for (int& i = it[u]; i < (int)g[u].size(); ++i) {
E& e = g[u][i];
if (e.cap > 0 && level[e.to] == level[u] + 1) {
ll d = dfs(e.to, t, min(f, e.cap));
if (d > 0) {
e.cap -= d;
g[e.to][e.rev].cap += d;
return d;
}
}
}
return 0;
}
// 最大流主函数
ll maxflow(int s, int t) {
ll fl = 0;
while (bfs(s, t)) {
fill(it.begin(), it.end(), 0);
ll f;
while ((f = dfs(s, t, LLONG_MAX)) > 0) fl += f;
}
return fl;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
ll c, d;
cin >> n >> m >> c >> d;
vector<string> grid(n);
for (int i = 0; i < n; i++) cin >> grid[i];
// 统计每行、每列的空格数(度数),以及所有空格的位置
vector<int> rowdeg(n, 0), coldeg(m, 0);
vector<pair<int, int>> cells;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == '.') {
cells.push_back({i, j});
rowdeg[i]++;
coldeg[j]++;
}
}
}
ll E = (ll)cells.size(); // 总空格数
// Δ = 最大行度数或最大列度数
int Delta = 0;
for (int i = 0; i < n; i++) Delta = max(Delta, rowdeg[i]);
for (int j = 0; j < m; j++) Delta = max(Delta, coldeg[j]);
// 建图节点编号:
// S = 0
// 行节点 1..n
// 列节点 n+1..n+m
// T = n+m+1
// 总节点数 V = n+m+2
int S = 0, T = n + m + 1, V = n + m + 2;
// W(k) = 行/列度数不超过 k 时,最多能覆盖多少个空格
// 等价于二分图 b-匹配的最大边数
auto W = [&](int k) -> ll {
Dinic din;
din.init(V);
// 源点连每一行,容量为 k
for (int i = 0; i < n; i++) din.add(S, 1 + i, k);
// 每一列连汇点,容量为 k
for (int j = 0; j < m; j++) din.add(1 + n + j, T, k);
// 每个空格:行连列,容量为 1
for (auto& p : cells) din.add(1 + p.first, 1 + n + p.second, 1);
return din.maxflow(S, T);
};
// 如果没有空格,直接输出 0
if (Delta == 0) {
cout << 0 << '\n';
return 0;
}
// 二分查找最优的 k
// cost(k) = c*k + d*(E - W(k))
// cost(k+1) - cost(k) = c - d * (W(k+1) - W(k))
// 找到最小的 k 使得 cost(k+1) - cost(k) >= 0
int lo = 0, hi = Delta - 1, k0 = Delta;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
ll inc = W(mid + 1) - W(mid); // 增加一种颜色能多覆盖多少空格
if (c - d * inc >= 0) {
// 增加颜色的代价 >= 节省的代价,最优 k 在左侧
k0 = mid;
hi = mid - 1;
} else {
// 增加颜色更划算,最优 k 在右侧
lo = mid + 1;
}
}
// 计算最终答案
ll ans = c * (ll)k0 + d * (E - W(k0));
cout << ans << '\n';
return 0;
}
第49届国际大学生编程设计竞赛成都站K---网络流
- 题目链接:第49届国际大学生编程设计竞赛成都站K
- 题目描述及思路:
text
Magical Set(CF Gym 105486 K)
【题目描述】
初始有一个包含 n 个不同整数的集合。每次操作:
- 选一个大于 1 的数 x,把它从集合中删除。
- 选一个 x 的真因子 y(y ≠ x),把 y 插入集合。
- 要求操作后集合中的数仍然互不相同。
- 每次操作产生 1 单位能量。
问最多能进行多少次操作(即最大能量)。
【输入】
第一行:n(1 ≤ n ≤ 300)
第二行:n 个不同的整数 a_i(1 ≤ a_i ≤ 10^9)
【输出】
一个整数,表示最大操作次数。
【样例1】
输入:
3
2 4 6
输出:
3
【样例2】
输入:
6
2 3 5 6 10 12
输出:
3
【思路解说】
【一、核心观察:操作的本质是质因子数的减少】
把每个数分解质因数,设 Ω(x) 为 x 的质因子个数(带重数,即 x 的"总指数和")。
例如:
Ω(2) = 1
Ω(4) = Ω(2²) = 2
Ω(6) = Ω(2 × 3) = 2
Ω(12) = Ω(2² × 3) = 3
每次操作把 x 变成它的真因子 y,则 Ω(y) < Ω(x)。
所以每次操作至少让 Ω 减少 1,最多让 Ω 减少 Ω(x) - 1(变成 1 时)。
总操作次数 = 所有数的 Ω 之和 - 最终所有数的 Ω 之和。
为了让操作次数最多,我们要让最终所有数的 Ω 之和**尽可能小**。
【二、最终状态的约束】
最终集合中的数必须互不相同。
初始集合有 n 个数,经过若干次操作后,集合大小仍然是 n(每次删一个、加一个)。
所以最终集合是 n 个互不相同的数,每个数都是某个初始数的因子(或初始数本身)。
我们的目标是:选择 n 个互不相同的数,使得它们的 Ω 之和最小。
但有一个约束:每个初始数 a_i 最终必须"变成"某个数,且这个数必须是 a_i 的因子。
同时,所有最终数必须互不相同。
【三、建模为二分图匹配】
把问题转化为二分图:
- 左侧:n 个初始数 a_i
- 右侧:所有可能出现的数(即所有 a_i 的所有因子,去重)
- 边:如果 y 是 x 的因子,则从左侧 x 连一条边到右侧 y
- 每个左侧节点必须匹配一个右侧节点(因为每个初始数最终要变成某个数)
- 每个右侧节点最多匹配一次(因为最终数互不相同)
匹配的代价:右侧节点 y 的 Ω(y) 越小,代价越小。
所以我们要找的是:**最小权完美匹配**(每个左侧节点必须匹配一个右侧节点)。
但注意:左侧节点可以匹配到它自己(即不操作),此时代价是 Ω(a_i)。
**答案 = sum Ω(a_i) - 最小权完美匹配的代价**
【四、为什么不能用简单的最大流 / 匈牙利】
因为每个右侧节点有权重 Ω(y),我们要最小化总权重。
这是一个**最小权二分图匹配**问题。
可以用 **费用流**(最小费用最大流)解决,但复杂度较高。
另一种方法是:**按 Ω 从小到大逐层增广**,类似"匈牙利算法 + 分层"的思想。
【五、更优的建模:按 Ω 分层,逐层增广】
观察:Ω(y) 越小,y 越"便宜"。
我们可以把右侧节点按 Ω 分层:Ω = 0, 1, 2, ..., maxΩ。
从小到大逐层激活右侧节点,然后跑增广路。
每次增广成功,就说明有一个左侧节点可以匹配到一个更便宜的右侧节点。
具体做法:
1) 把所有 a_i 的因子去重,按 Ω 分层。
2) 从 Ω = 0 开始,逐层激活右侧节点。
3) 每激活一层,就跑一次增广(类似匈牙利算法)。
4) 记录每个左侧节点最终匹配到的右侧节点的 Ω 之和。
5) 答案 = sum Ω(a_i) - 匹配总代价。
**关键优化:**
- 不要把"因子格点边"全部建出来(规模可达几十万),否则会超时。
- 而是在增广时,**动态枚举左侧节点的因子**,只枚举当前激活层的因子。
- 这样每次增广的边数大大减少,总复杂度 O(n × 因子数 × 增广次数) 可以接受。
【六、为什么不能把所有不同因子都建节点、还建因子格点边】
如果建出所有因子节点和所有因子之间的边,规模可能达到几十万甚至上百万。
再跑约 300 次带堆的最短路增广(费用流),复杂度会爆炸,直接超时。
所以必须**动态枚举因子**,只枚举当前需要的层,避免建出所有边。
【七、正确代码的核心思路】
- 对每个 a_i 分解质因数,枚举它的所有因子(用 DFS 枚举质因数的指数组合)。
- 把所有因子去重,按 Ω 分层。
- 从左到右逐层激活因子,跑增广路(BFS 找增广路)。
- 增广时,从左侧节点出发,枚举它的所有因子,只考虑已激活的因子。
- 记录每个左侧节点最终匹配到的因子的 Ω 值。
- 答案 = sum Ω(a_i) - 匹配总代价。
【八、复杂度分析】
- 每个数的因子数:O(√a_i),a_i ≤ 10^9,实际因子数不超过 1344。
- 总因子数(去重后):≤ n × 1344 ≈ 4 × 10^5。
- 增广次数:O(n × 层数),层数 ≤ 30。
- 每次增广的边数:O(n × 因子数)。
- 总复杂度:O(n² × 因子数) ≈ 300² × 1344 ≈ 1.2 × 10^8,可以过。
【九、注意事项】
- 因子要排序去重。
- 按 Ω 从小到大逐层激活。
- 增广时只考虑已激活的因子。
- 答案可能很大,要用 long long。
- 不要建出所有因子之间的边,否则会超时。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin>>n;
vector<long long> a(n);
for (auto &x : a) cin >> x;
// vid: 因子值 -> 因子编号
// omegaV: 每个因子的 Ω 值(质因子个数,带重数)
// adj[i]: 第 i 个数 a[i] 的所有因子编号
unordered_map<long long, int> vid;
vid.reserve(1024);
vector<int> omegaV;
vector<vector<int>> adj(n);
long long sumOmega = 0;
for (int i = 0; i < n; i++) {
long long y = a[i];
// 对 a[i] 分解质因数
vector<pair<long long, int>> fac;
for (long long p = 2; p * p <= y; p++) {
if (y % p == 0) {
int c = 0;
while (y % p == 0) { y /= p; c++; }
fac.push_back({p, c});
}
}
if (y > 1) fac.push_back({y, 1});
// 计算 a[i] 的 Ω 值
int om = 0;
for (auto &pr : fac) om += pr.second;
sumOmega += om;
// 用 DFS 枚举 a[i] 的所有因子(包括 1 和 a[i] 本身)
// 每个因子记录它的 Ω 值 co
vector<int> &loc = adj[i];
function<void(int, long long, int)> dfs = [&](int k, long long cur, int co) {
if (k == (int)fac.size()) {
auto it = vid.find(cur);
int id;
if (it == vid.end()) {
id = (int)omegaV.size();
vid[cur] = id;
omegaV.push_back(co);
} else {
id = it->second;
}
loc.push_back(id);
return;
}
long long v = cur;
for (int e = 0; e <= fac[k].second; e++) {
dfs(k + 1, v, co + e);
v *= fac[k].first;
}
};
dfs(0, 1, 0);
}
int D = (int)omegaV.size(); // 因子总数(去重后)
// 按 Ω 值分层:byOm[k] = 所有 Ω 值为 k 的因子编号
int maxOm = 0;
for (int o : omegaV) if (o > maxOm) maxOm = o;
vector<vector<int>> byOm(maxOm + 1);
for (int v = 0; v < D; v++) byOm[omegaV[v]].push_back(v);
// matchV[v]: 因子 v 匹配的左侧节点(-1 表示未匹配)
// matchE[i]: 左侧节点 i 匹配的因子(-1 表示未匹配)
vector<int> matchV(D, -1), matchE(n, -1);
// active[v]: 因子 v 是否已激活(即 Ω 值 <= 当前层)
vector<char> active(D, 0);
// BFS 增广时的辅助数组
vector<int> parE(n), parV(D), markE(n, 0), markV(D, 0);
int visMarkE = 0, visMarkV = 0;
vector<int> q(n);
// 从小到大逐层激活因子,每激活一层就跑一次增广
for (int k = 0; k <= maxOm; k++) {
// 激活所有 Ω 值为 k 的因子
for (int v : byOm[k]) active[v] = 1;
// 反复增广直到找不到增广路
while (true) {
++visMarkE; ++visMarkV;
// 把所有未匹配的左侧节点加入队列
int qh = 0, qt = 0;
for (int i = 0; i < n; i++) {
if (matchE[i] == -1) {
markE[i] = visMarkE;
parE[i] = -1;
q[qt++] = i;
}
}
int endV = -1; // 找到的增广路终点(未匹配的因子)
// BFS 找增广路
while (qh < qt && endV == -1) {
int u = q[qh++];
for (int v : adj[u]) {
// 只考虑已激活的因子,且未访问过
if (!active[v] || markV[v] == visMarkV) continue;
markV[v] = visMarkV;
parV[v] = u;
int w = matchV[v]; // v 当前匹配的左侧节点
if (w == -1) {
// 找到未匹配的因子,增广路结束
endV = v;
break;
}
// 继续从 w 出发找增广路
if (markE[w] != visMarkE) {
markE[w] = visMarkE;
parE[w] = v;
q[qt++] = w;
}
}
}
// 没有增广路,当前层处理完毕
if (endV == -1) break;
// 沿增广路更新匹配
int v = endV;
while (v != -1) {
int u = parV[v]; // v 的新匹配
int nv = parE[u]; // u 原来的匹配
matchV[v] = u;
matchE[u] = v;
v = nv;
}
}
}
// 计算匹配的总代价(所有左侧节点匹配到的因子的 Ω 值之和)
long long cost = 0;
for (int i = 0; i < n; i++) cost += omegaV[matchE[i]];
// 答案 = 总 Ω 值 - 匹配的最小代价
cout << sumOmega - cost << '\n';
return 0;
}
2024年ICPC上海站E---Tarjan 算法
-
题目链接:2024年ICPC上海站E
-
题目描述及思路:
textEqual Measure(QOJ #9041 / Shanghai Regional 2024) 【题目描述】 给定一个 n 个点、m 条边的无向连通图(无自环、无重边)。 定义一条"路线"为一个序列 a1, a2, ..., ak(k >= 3),满足: - 所有 ai 互不相同 - 相邻两点之间有边 - ak 与 a1 之间有边 也就是说,路线就是一个简单环。 问:图中所有简单环的长度是否都相同。 若图中无环,视为满足条件。 【输入】 第一行:T,表示测试用例个数。 每个测试用例: 第一行:n m 接下来 m 行:每行 u_i v_i,表示一条边。 保证所有测试用例的 n 之和、m 之和均不超过 5e5,图连通。 【输出】 每个测试用例输出一行:Yes 或 No。 【样例1】 输入: 1 5 6 1 2 2 3 3 1 1 4 4 5 5 1 输出: Yes 【样例2】 输入: 2 2 1 1 2 5 6 1 2 2 3 3 1 2 4 3 5 4 5 输出: Yes No 【样例3】 输入: 2 4 5 1 2 2 3 3 1 2 4 4 1 5 6 1 2 2 3 1 4 1 5 4 3 5 3 输出: No Yes 【思路解说】 【一、核心观察】 如果两个环共享一条边,它们的对称差会形成一个新的环, 新环长度 = 两环长度之和 - 2 * 共享边数。 若两环等长 L,则新环长为 2L - 2,与 L 不同(L >= 3),矛盾。 所以图中任意两个环不能共享边。 如果两个环共享一个割点,它们属于不同的点双连通分量, 各自必须是单环,且长度相同。 如果某个点双连通分量不是单环,它一定包含至少两个环。 此时该分量必须恰好有两个度数 >= 3 的顶点 s, t, 其余点度数为 2,即该块是 s 到 t 的若干条路径拼成的。 每条路径长度必须相同,否则会产生不同长度的环。 所有含环块的环长必须一致,否则图中存在两个不同长度的环。 【二、核心结论】 一个无向图中所有简单环长度都相等,当且仅当: 每个点双连通分量(v-BCC)要么是桥(无环), 要么是单环, 要么是"两个度数 >= 3 的顶点之间夹着若干条等长路径"的结构, 且所有含环块的环长必须一致。 【三、算法流程】 1) 用迭代式 Tarjan 求所有点双连通分量(v-BCC), 同时用边栈记录每个块包含的边。 2) 对每个 v-BCC: - 若边数 <= 1:是桥,跳过; - 若块内所有点度数均为 2:是单环,环长 = 边数; - 若块内恰好有两个度数 >= 3 的顶点 s, t, 且 deg[s] == deg[t],其余点度数为 2: 枚举 s -> t 的每条路径,长度必须全部相等; 环长记为 2 * firstL; - 其他情况:直接输出 No。 3) 所有含环块的环长必须相同,否则输出 No。 4) 全部通过则输出 Yes。 【四、复杂度】 Tarjan 求 v-BCC:O(n + m) 处理每个 v-BCC:O(块内边数),总和 O(m) 枚举 s -> t 路径:O(块内边数),总和 O(m) 总复杂度:O(n + m) 【五、注意事项】 - 必须用迭代式 Tarjan:n 可达 5e5,递归会爆栈。 - 用边栈记录 v-BCC:点栈无法区分同一个点属于多个 v-BCC 的情况。 - 桥的判断:块内边数 <= 1 即为桥,直接跳过。 - 单环的判断:块内所有点度数为 2,环长 = 边数。 - 双路径结构的判断:恰有两个度数 >= 3 的顶点 s, t, 且 deg[s] == deg[t],其余点度数为 2。 - 环长一致性:所有含环块的环长必须相同,否则输出 No。 - 输入输出优化:T 可达 5e5,必须用 fread / fwrite 手动缓冲。 - 多测试用例清空:用时间戳代替 memset,避免反复清空大数组。- 代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
static const int MAXN = 500005;
static const int MAXM = 500005;
// 快速输入:手写 fread 缓冲区
static char ibuf[1 << 24];
static int ipos = 0, ilen = 0;
static inline int gc() {
if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return -1; }
return ibuf[ipos++];
}
static inline int readInt() {
int c = gc();
while (c != -1 && (c < '0' || c > '9')) c = gc();
int x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); }
return x;
}
// 链式前向星存图
int head_[MAXN], enxt[2 * MAXM], eto[2 * MAXM], eid_[2 * MAXM];
// eu[i], ev[i] 存第 i 条边的两个端点
int eu[MAXM], ev[MAXM];
// dfn: 时间戳, lowv: 追溯值, curEdge: 当前节点正在处理的出边
int dfn[MAXN], lowv[MAXN], curEdge[MAXN];
// markv: 时间戳标记, degv: 块内度数, cntb: 邻居计数, nb1/nb2: 两个邻居
int markv[MAXN], degv[MAXN], cntb[MAXN], nb1[MAXN], nb2[MAXN];
// estack_: 边栈, stkv/stkpe: 迭代式 Tarjan 的节点栈和对应父边, blk: 当前块的边, verts: 当前块的点
int estack_[MAXM], stkv[MAXN], stkpe[MAXN], blk[MAXM], verts[MAXM];
int main() {
int T = readInt();
string out;
out.reserve((size_t)T * 4);
int ts = 0, timer;
while (T--) {
int n = readInt(), m = readInt();
// 初始化邻接表头
for (int i = 0; i < n; i++) head_[i] = -1;
int ec = 0;
// 建图:每条无向边拆成两条有向边
for (int i = 0; i < m; i++) {
int u = readInt() - 1, v = readInt() - 1;
eu[i] = u; ev[i] = v;
eto[ec] = v; eid_[ec] = i; enxt[ec] = head_[u]; head_[u] = ec++;
eto[ec] = u; eid_[ec] = i; enxt[ec] = head_[v]; head_[v] = ec++;
}
bool ok = true; // 是否所有环长相同
long long K = -1; // 记录第一个含环块的环长
// 初始化 DFS 相关数组
for (int i = 0; i < n; i++) { dfn[i] = -1; curEdge[i] = head_[i]; }
timer = 0;
// 处理一个点双连通分量(v-BCC),边集为 blk[0..blen)
auto process = [&](int blen) {
if (blen <= 1) return; // 桥:无环,跳过
++ts; // 用时间戳代替 memset
int vn = 0; // 块内点数
// 统计块内每个点的度数,并收集所有点
for (int j = 0; j < blen; j++) {
int ei = blk[j];
int u = eu[ei], v = ev[ei];
if (markv[u] != ts) { markv[u] = ts; degv[u] = 0; verts[vn++] = u; }
if (markv[v] != ts) { markv[v] = ts; degv[v] = 0; verts[vn++] = v; }
degv[u]++; degv[v]++;
}
// 判断是否所有点度数都为 2(单环)
bool all2 = true;
for (int j = 0; j < vn; j++) if (degv[verts[j]] != 2) { all2 = false; break; }
long long k;
if (all2) {
// 情况 1:单环,环长 = 边数
k = blen;
} else {
// 情况 2:寻找恰好两个度数 >= 3 的顶点 s, t
int s = -1, t = -1, nhi = 0;
for (int j = 0; j < vn; j++) {
int x = verts[j];
if (degv[x] >= 3) { ++nhi; if (nhi == 1) s = x; else if (nhi == 2) t = x; }
}
// 必须恰好两个度数 >= 3 的顶点,且它们度数相等
if (nhi != 2 || degv[s] != degv[t]) { ok = false; return; }
// 其余点度数必须为 2
for (int j = 0; j < vn; j++) {
int x = verts[j];
if (x != s && x != t && degv[x] != 2) { ok = false; return; }
}
// 记录每个点的两个邻居(用于沿路径走)
for (int j = 0; j < blen; j++) {
int ei = blk[j], u = eu[ei], v = ev[ei];
if (cntb[u] != ts) { cntb[u] = ts; nb1[u] = v; } else nb2[u] = v;
if (cntb[v] != ts) { cntb[v] = ts; nb1[v] = u; } else nb2[v] = u;
}
long long firstL = -1; // 第一条 s->t 路径的长度
int paths = 0; // 路径条数
// 枚举所有从 s 出发的边,沿路径走到 t
for (int j = 0; j < blen; j++) {
int ei = blk[j], u = eu[ei], v = ev[ei];
int start;
if (u == s) start = v;
else if (v == s) start = u;
else continue;
++paths;
int prev = s, cur = start;
long long L = 1; // 路径长度(边数)
while (cur != t) {
// 从 cur 的两个邻居中选不是 prev 的那个
int nx = (nb1[cur] == prev) ? nb2[cur] : nb1[cur];
prev = cur; cur = nx; ++L;
}
// 所有路径长度必须相同
if (firstL == -1) firstL = L;
else if (firstL != L) { ok = false; return; }
}
// 路径条数必须等于 s 的度数
if (paths != degv[s]) { ok = false; return; }
// 该块产生的环长 = 2 * 单条路径长度
k = 2 * firstL;
}
// 所有含环块的环长必须一致
if (K == -1) K = k;
else if (K != k) { ok = false; return; }
};
// 迭代式 Tarjan 求点双连通分量
for (int root = 0; root < n && ok; root++) {
if (dfn[root] != -1 || head_[root] == -1) continue;
dfn[root] = lowv[root] = timer++;
int sp = 0;
stkv[sp] = root; stkpe[sp] = -1; ++sp; // 入栈根节点,父边为 -1
int esz = 0; // 边栈大小
while (sp > 0 && ok) {
int v = stkv[sp - 1]; // 当前节点
int pe = stkpe[sp - 1]; // 进入 v 的父边
int ce = curEdge[v]; // 当前正在处理的出边
if (ce != -1) {
curEdge[v] = enxt[ce]; // 指向下一条出边
int ei = eid_[ce], w = eto[ce];
if (ei == pe) continue; // 跳过父边(无向边)
if (dfn[w] == -1) {
// 树边:压入边栈,继续 DFS
estack_[esz++] = ei;
dfn[w] = lowv[w] = timer++;
stkv[sp] = w; stkpe[sp] = ei; ++sp;
} else if (dfn[w] < dfn[v]) {
// 后向边:压入边栈,更新 lowv
estack_[esz++] = ei;
if (dfn[w] < lowv[v]) lowv[v] = dfn[w];
}
} else {
// 当前节点出边处理完毕,回溯
--sp;
if (sp > 0) {
int p = stkv[sp - 1]; // 父节点
if (lowv[v] < lowv[p]) lowv[p] = lowv[v];
// 发现一个点双连通分量
if (lowv[v] >= dfn[p]) {
int blen = 0, e;
// 从边栈弹出边,直到弹出父边 pe
do { e = estack_[--esz]; blk[blen++] = e; } while (e != pe);
process(blen); // 处理该块
}
}
}
}
}
out += ok ? "Yes\n" : "No\n";
}
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
DP区域赛题
第45届国际大学生程序设计竞赛济南站L---数位DP
- 题目链接:第45届国际大学生程序设计竞赛济南站L
- 题目描述及思路:
text
Bit Sequence(ICPC 济南 2020 L / 牛客 10662 L)
【题目描述】
设 f(x) 表示 x 的二进制表示中 1 的个数。
给定一个长度为 m 的 01 序列 a_0, a_1, ..., a_{m-1},以及一个整数 L。
问有多少个整数 x ∈ [0, L] 满足:
∀ i ∈ [0, m-1], f(x + i) mod 2 = a_i
【输入】
第一行:T,测试用例个数(1 ≤ T ≤ 1000)
每个测试用例:
第一行:m, L(1 ≤ m ≤ 100,0 ≤ L ≤ 10^18)
第二行:m 个整数 a_0, ..., a_{m-1}(a_i ∈ {0, 1})
【输出】
每个测试用例输出一行,一个整数表示答案。
【样例1】
输入:
3
3 10
0 1 0
1 1000
0
9 1000000
1 0 1 1 0 1 0 0 1
输出:
2
501
41667
【思路解说】
【一、核心观察:拆成高位和低 K 位】
因为 m ≤ 100,所以 x + i 只会影响 x 的低 7 位(2^7 = 128 > 100)。
进位最多影响到第 7 位,不会影响更高位。
所以我们可以把 x 拆成:
x = high × 2^K + low
其中 K = 7,low ∈ [0, 2^K - 1],high = ⌊x / 2^K⌋。
【二、枚举 low,统计 high】
对于每个 low ∈ [0, 2^K - 1]:
1) 计算 x + i 的低 K 位和进位:
s = low + i
如果 s ≥ 2^K,则 s -= 2^K,进位 c = 1;否则 c = 0。
2) 对于每个 i,需要满足:
f(high + c) mod 2 = a_i ^ (popcount(s) mod 2)
因为 f(x + i) = f(high + c) + f(s),而 f(s) 的奇偶性就是 popcount(s) mod 2。
3) 把约束分成两类:
- 对于 c = 0 的 i,需要 f(high) mod 2 = P0
- 对于 c = 1 的 i,需要 f(high + 1) mod 2 = P1
如果同一类中出现不同的需求,则这个 low 不可行。
4) 如果可行,统计 high ∈ [0, Q] 满足上述条件的个数:
Q = ⌊(L - low) / 2^K⌋(如果 low > L,则跳过)
【三、统计 high 的个数】
问题转化为:统计 high ∈ [0, Q] 满足:
- f(high) mod 2 = P0(如果 P0 存在)
- f(high + 1) mod 2 = P1(如果 P1 存在)
关键:f(high + 1) 的奇偶性与 f(high) 有关。
如果 high 的二进制末尾有 t 个连续的 1,则:
high + 1 会把这 t 个 1 变成 0,并在第 t 位加 1。
所以 f(high + 1) = f(high) - t + 1。
奇偶性变化:f(high + 1) mod 2 = (f(high) - t + 1) mod 2。
因此,条件 f(high + 1) mod 2 = P1 等价于:
(f(high) - t + 1) mod 2 = P1
即 f(high) mod 2 = (P1 + t - 1) mod 2
所以我们需要统计 high ∈ [0, Q] 满足:
- f(high) mod 2 = P0
- 如果 high 的末尾有 t 个连续的 1,则 f(high) mod 2 = (P1 + t - 1) mod 2
这两个条件可以合并为:对于每个 t,统计末尾恰好有 t 个连续 1 的 high 中,
满足 f(high) mod 2 = P0 且 (P1 + t - 1) mod 2 = P0 的个数。
但注意:末尾恰好有 t 个连续 1 意味着 high 的第 t 位是 0(或者 high = 2^t - 1 时全是 1)。
我们可以枚举 t,统计满足条件的 high 个数。
【四、countParity 函数】
统计 b ∈ [0, B] 中 popcount(b) mod 2 = p 的个数。
用数位 DP 或组合数学:
- 从高位到低位枚举,如果当前位是 1,可以选 0,剩下的低位任意,贡献 2^(剩余位数-1)。
- 最后加上 B 本身的贡献。
【五、countQ 函数】
统计 q ∈ [0, Q] 满足:
- popcount(q) mod 2 = P0
- 如果 needT,则 trailingOnes(q) mod 2 = T0
枚举 trailingOnes 的长度 t:
- q 的末尾恰好有 t 个 1,第 t 位是 0(或 q = 2^t - 1)。
- q 可以写成 base + (B << (t+1)),其中 base = 2^t - 1。
- 此时 popcount(q) = t + popcount(B)。
- 所以需要 popcount(B) mod 2 = (P0 - t) mod 2。
- 统计 B ∈ [0, (Q - base) / 2^(t+1)] 中 popcount(B) mod 2 = (P0 - t) mod 2 的个数。
- 用 countParity 统计。
【六、算法流程】
1) 枚举 low ∈ [0, 2^K - 1]:
- 如果 low > L,跳过。
- 计算每个 i 的进位 c 和低 K 位的 popcount。
- 确定 P0 和 P1,如果冲突则跳过。
- 计算 Q = (L - low) / 2^K。
- 如果 P1 不存在,直接用 countParity(Q, P0)。
- 否则,计算 T0 = (P0 + P1 + 1) mod 2,用 countQ(Q, P0, true, T0)。
- 累加到答案。
2) 输出答案。
【七、复杂度】
- 枚举 low:2^7 = 128 次。
- 每次统计 high:O(log Q) 或 O(K)。
- 总复杂度:O(2^K × (m + log L)),可以过。
【八、注意事项】
- K = 7,因为 m ≤ 100 < 128。
- 注意 low > L 的情况要跳过。
- countParity 和 countQ 要处理边界(B < 0 返回 0)。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 统计 b ∈ [0, B] 中 popcount(b) % 2 == p 的个数
static ll countParity(ll B, int p) {
if (B < 0) return 0;
ll res = 0;
int par = 0; // 当前已经确定的位的 popcount 奇偶性
for (int i = 62; i >= 0; i--) {
if ((B >> i) & 1) {
// 当前位是 1,可以选 0,剩下的 i 位任意
if (i == 0) {
// 最后一位,如果选 0,检查奇偶性是否满足
if (((p ^ par) & 1) == 0) res += 1;
} else {
// 剩下的 i 位任意,其中奇偶性为 0 的个数是 2^(i-1)
res += (1LL << (i - 1));
}
par ^= 1; // 当前位选 1,奇偶性翻转
}
}
// 最后 B 本身也要检查
if (par == p) res += 1;
return res;
}
// 统计 q ∈ [0, Q] 满足:
// popcount(q) % 2 == P0
// 如果 needT,还要求 trailingOnes(q) % 2 == T0
static ll countQ(ll Q, int P0, bool needT, int T0) {
if (Q < 0) return 0;
if (!needT) return countParity(Q, P0);
ll res = 0;
// 枚举 q 末尾连续 1 的个数 t
for (int t = 0; t <= 62; t++) {
// base = 2^t - 1,即末尾 t 个 1,前面全是 0
ll base = (t == 0) ? 0 : ((1LL << t) - 1);
if (base > Q) break;
// 要求 trailingOnes(q) % 2 == T0
if ((t & 1) == T0) {
// q = base + (B << (t+1))
// 其中 B 是高位部分,且 B 的最低位必须是 0(否则末尾会有更多 1)
// 但这里我们统计的是 trailingOnes 恰好为 t 的 q
// 实际上,B 的最低位必须是 0,否则 trailingOnes 会大于 t
// 但为了简化,我们统计所有 B,然后依靠 trailingOnes 的奇偶性过滤
// 注意:B 可以任意,但需要满足 q <= Q
ll B = (Q - base) >> (t + 1);
// popcount(q) = t + popcount(B)
// 所以需要 popcount(B) % 2 == (P0 - t) % 2
int p = (P0 + t) & 1;
res += countParity(B, p);
}
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
const int K = 7; // 低 7 位
const int M = 1 << K; // 128
while (T--) {
int m;
ll L;
cin >> m >> L;
vector<int> a(m);
for (auto &x : a) cin >> x;
ll ans = 0;
// 枚举低 7 位的值 r
for (int r = 0; r < M; r++) {
if ((ll)r > L) break; // 如果 r > L,跳过
int P0 = -1, P1 = -1; // P0: 进位 0 时的需求; P1: 进位 1 时的需求
bool ok = true;
for (int i = 0; i < m; i++) {
int s = r + i;
int c = 0; // 进位
if (s >= M) { s -= M; c = 1; } // 低 7 位溢出,产生进位
// 需要 f(high + c) % 2 == a[i] ^ (popcount(s) % 2)
int need = a[i] ^ (__builtin_popcount((unsigned)s) & 1);
if (c == 0) {
if (P0 == -1) P0 = need;
else if (P0 != need) { ok = false; break; }
} else {
if (P1 == -1) P1 = need;
else if (P1 != need) { ok = false; break; }
}
}
if (!ok) continue;
// high 的最大值
ll Q = (L - r) / M;
// 如果 P1 不存在,只需要统计 f(high) % 2 == P0 的 high
// 否则,需要同时统计 f(high) % 2 == P0 和 f(high + 1) % 2 == P1
bool needT = (P1 != -1);
int T0 = 0;
if (needT) {
// f(high + 1) % 2 == P1
// 如果 high 末尾有 t 个连续 1,则 f(high + 1) = f(high) - t + 1
// 所以 f(high) % 2 == (P1 + t - 1) % 2
// 又需要 f(high) % 2 == P0
// 所以 (P1 + t - 1) % 2 == P0
// 即 t % 2 == (P0 + P1 + 1) % 2
T0 = (P0 + P1 + 1) & 1;
}
ans += countQ(Q, P0, needT, T0);
}
cout << ans << '\n';
}
return 0;
}
第六届ccpc长春站A(第一题)---背包问题
- 题目链接:第六届ccpc长春站A(第一题)
- 题目描述及思路:
text
Krypton(CCPC 2020 长春站 A / CF Gym 102832 A)
【题目描述】
有一款手机游戏,里面有 7 个充值档位。每个档位有:
- 价格(RMB 元)
- 正常充值能获得的点券数
- 首次充值额外奖励的点券数
七个档位的数据如下:
| 价格 | 正常点券 | 首充奖励 |
|------|----------|----------|
| 1 | 10 | 8 |
| 6 | 60 | 18 |
| 28 | 280 | 28 |
| 88 | 880 | 58 |
| 198 | 1980 | 128 |
| 328 | 3280 | 198 |
| 648 | 6480 | 388 |
每个档位可以充值任意多次,但**只有第一次**充值该档位时才能获得首充奖励。
Kelo 现在有 n 元,他要把这 n 元全部充值进游戏,希望获得尽可能多的点券。
求最多能获得多少点券。
【输入】
一行一个整数 n(1 ≤ n ≤ 2000)。
【输出】
一个整数,表示最多能获得的点券数。
【样例1】
输入:
100
输出:
1084
【样例2】
输入:
198
输出:
2108
【思路解说】
【一、问题本质:混合背包】
这题是一个典型的**混合背包**问题:
- 每个档位可以充值**无限次**(完全背包)
- 但每个档位的**首充奖励只能用一次**(01 背包)
所以,我们需要对每个档位分别处理:
1) 先当作 01 背包,处理"首充奖励"的情况(倒序遍历容量)
2) 再当作完全背包,处理"正常充值"的情况(正序遍历容量)
【二、状态定义】
设 dp[j] 表示花费 j 元能获得的最大点券数。
初始时 dp[0] = 0,其余为 0(因为可以不充值)。
【三、转移方程】
对于第 i 个档位(价格 price[i],正常券数 normal[i],首充奖励 bonus[i]):
1) 01 背包(首充):
dp[j] = max(dp[j], dp[j - price[i]] + normal[i] + bonus[i])
倒序遍历 j 从 n 到 price[i]。
2) 完全背包(正常):
dp[j] = max(dp[j], dp[j - price[i]] + normal[i])
正序遍历 j 从 price[i] 到 n。
**为什么先 01 再完全?**
- 先处理 01,保证每个档位的首充奖励最多只用一次。
- 再处理完全,允许正常充值无限次。
- 如果先完全再 01,可能会导致首充奖励被重复使用。
【四、算法流程】
1) 读入 n。
2) 初始化 dp 数组,dp[0] = 0,其余为 0。
3) 对于每个档位 i(0 到 6):
- 01 背包:倒序遍历 j,更新 dp[j]。
- 完全背包:正序遍历 j,更新 dp[j]。
4) 输出 dp[n]。
【五、样例验证】
n = 100:
最优方案:88 + 6 + 1×6 = 100
- 88 档首充:880 + 58 = 938
- 6 档首充:60 + 18 = 78
- 1 档首充 + 正常×5:10 + 8 + 10×5 = 68
总计:938 + 78 + 68 = 1084 ✓
n = 198:
最优方案:198 档首充:1980 + 128 = 2108 ✓
【六、复杂度】
- 时间复杂度:O(7 × n),n ≤ 2000,非常快。
- 空间复杂度:O(n)。
【七、注意事项】
- dp 数组初始化为 0,因为可以不充值任何档位。
- 01 背包必须倒序遍历,完全背包必须正序遍历。
- 简单代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// 七个充值档位:价格、正常券数、首充奖励
int price[7] = {1, 6, 28, 88, 198, 328, 648};
int normal[7] = {10, 60, 280, 880, 1980, 3280, 6480};
int bonus[7] = {8, 18, 28, 58, 128, 198, 388};
// dp[i] = 花费 i 元能获得的最大券数
vector<int> dp(n + 1, 0);
for (int i = 0; i < 7; i++) {
// 先处理首充奖励(01 背包,每个档位最多用一次)
for (int j = n; j >= price[i]; j--) {
dp[j] = max(dp[j], dp[j - price[i]] + normal[i] + bonus[i]);
}
// 再处理正常充值(完全背包,可以无限次)
for (int j = price[i]; j <= n; j++) {
dp[j] = max(dp[j], dp[j - price[i]] + normal[i]);
}
}
cout << dp[n] << '\n';
return 0;
}
2020年秦皇岛站K---树形DP
- 题目链接:2020年秦皇岛站K
- 题目描述及思路如下:
text
Kingdom's Power(2020 CCPC 秦皇岛站 K / CF Gym 102769 K)
【题目描述】
Alex 是一个职业游戏玩家。游戏中的王国形成一棵以 1 为根的有根树。
Alex 的王国 1 是这棵树的根。初始时,Alex 有无限多的军队,全部位于根节点 1。
每周,Alex 可以命令一支军队向相邻的王国移动一步。
如果一支军队到达某个王国,该王国立即被 Alex 占领。
Alex 想尽快占领所有王国,求最少需要多少周。
【输入】
第一行:T,测试用例个数(1 ≤ T ≤ 10^5)
每个测试用例:
第一行:n(1 ≤ n ≤ 10^6)
第二行:n-1 个整数 f_2, f_3, ..., f_n(1 ≤ f_i < i),表示 f_i 和 i 之间有一条边。
保证所有测试用例的 n 之和不超过 5 × 10^6。
【输出】
对于每个测试用例,输出一行 "Case #x: y",其中 x 是测试用例编号(从 1 开始),y 是最少周数。
【样例1】
输入:
2
3
1 1
6
1 2 3 4 4
输出:
Case #1: 2
Case #2: 6
【思路解说】
【一、问题本质:树形 DP】
- 从根节点出发,派出多支军队去占领所有节点。
- 每支军队从根出发,可以沿着一条路径一直往下走。
- 总时间 = 所有军队移动步数的总和。
- 我们需要决定每个节点的子树用哪支军队去访问,以及是否需要从根重新派兵。
【二、状态定义】
对于每个节点 u,定义以下数组:
- `dep[u]`:节点 u 的深度(根节点深度为 0)。
- `dp[u]`:占领以 u 为根的子树所需的最小总步数。
- `mxdep[u]`:以 u 为根的子树中,最深的叶子节点的深度。
- `mxcost[u]`:以 u 为根的子树中,单条路径的最大代价。
- `sumcost[u]`:以 u 为根的子树中,所有路径的代价之和。
**为什么需要这些数组?**
- 对于节点 u 的每个儿子 v,我们需要决定:
a) 从根重新派一支军队去 v(代价 = dep[u] + 1)
b) 让当前军队走进 v 的子树再退回(代价 = mxdep[v] - dep[u] + 1)
- 我们选择代价较小的方案。
- `mxcost[u]` 记录这些方案中最大的代价(用于合并)。
- `sumcost[u]` 记录所有方案的代价之和。
【三、合并过程(后序遍历)】
因为 f_i < i,所以节点编号本身就是拓扑序,**倒序遍历即为后序遍历**(儿子先于父亲完成)。
对于每个节点 v(从 n 到 1):
1) `dp[v] += sumcost[v] - mxcost[v]`
为什么?因为 `mxcost[v]` 对应的那条路径可以继续往上走(连接到父亲),
不需要新派军队,所以这部分代价不应该计入 dp[v]。
2) 如果 v == 1,结束。
3) 令 p = par[v](v 的父亲)。
4) 更新 `mxdep[p] = max(mxdep[p], mxdep[v])`。
5) `dp[p] += dp[v]`。
6) 计算两种方案的代价:
- 从根重新派兵:`cost = dep[p] + 1`
- 走进子树再退回:`alt = mxdep[v] - dep[p] + 1`
- 取较小值:`cost = min(cost, alt)`
7) 更新 `mxcost[p] = max(mxcost[p], cost)`。
8) `sumcost[p] += cost`。
【四、为什么这样是对的?】
- 对于每个节点,我们需要为它的每个儿子决定一支军队的路径。
- 如果从根重新派兵,代价是 `dep[p] + 1`(从根走到 p 再走一步到儿子)。
- 如果走进子树再退回,代价是 `mxdep[v] - dep[p] + 1`(从 p 走到子树最深叶子再退回一步)。
- 取较小值可以最小化总代价。
- `mxcost` 记录最大代价,用于后续合并(因为这条路径可以继续往上走)。
- `sumcost` 记录总代价,用于计算 dp。
【五、算法流程】
1) 读入 n 和 f 数组,初始化:
dep[1] = 0, dp[1] = 0, mxdep[1] = 0, mxcost[1] = 1, sumcost[1] = 1
2) 对于 i 从 2 到 n:
读入 f,设置 par[i] = f, dep[i] = dep[f] + 1
dp[i] = 0, mxdep[i] = dep[i], mxcost[i] = 1, sumcost[i] = 1
3) 倒序遍历 v 从 n 到 1:
dp[v] += sumcost[v] - mxcost[v]
如果 v == 1,结束
p = par[v]
mxdep[p] = max(mxdep[p], mxdep[v])
dp[p] += dp[v]
cost = min(dep[p] + 1, mxdep[v] - dep[p] + 1)
mxcost[p] = max(mxcost[p], cost)
sumcost[p] += cost
4) 输出 dp[1]。
【六、样例验证】
样例1:n = 3, f = [1, 1]
树:1 -> 2, 1 -> 3
dep: 1=0, 2=1, 3=1
倒序处理:
v=3: dp[3] += 1-1 = 0
p=1: mxdep[1]=max(0,1)=1, dp[1]+=0
cost = min(0+1, 1-0+1) = min(1, 2) = 1
mxcost[1]=max(1,1)=1, sumcost[1]=1+1=2
v=2: dp[2] += 0
p=1: mxdep[1]=max(1,1)=1, dp[1]+=0
cost = min(1, 1-0+1) = min(1, 2) = 1
mxcost[1]=max(1,1)=1, sumcost[1]=2+1=3
v=1: dp[1] += 3-1 = 2
答案 = 2 ✓
样例2:n = 6, f = [1, 2, 3, 4, 4]
树:1 -> 2 -> 3 -> 4 -> 5, 4 -> 6
dep: 1=0, 2=1, 3=2, 4=3, 5=4, 6=4
倒序处理:
v=6: dp[6]=0, p=4: mxdep[4]=max(3,4)=4
cost = min(3+1=4, 4-3+1=2) = 2
mxcost[4]=max(1,2)=2, sumcost[4]=1+2=3
v=5: dp[5]=0, p=4: mxdep[4]=max(4,4)=4
cost = min(4, 4-3+1=2) = 2
mxcost[4]=max(2,2)=2, sumcost[4]=3+2=5
v=4: dp[4] += 5-2 = 3, p=3: mxdep[3]=max(2,4)=4
cost = min(2+1=3, 4-2+1=3) = 3
mxcost[3]=max(1,3)=3, sumcost[3]=1+3=4
v=3: dp[3] += 4-3 = 1, p=2: mxdep[2]=max(1,4)=4
cost = min(1+1=2, 4-1+1=4) = 2
mxcost[2]=max(1,2)=2, sumcost[2]=1+2=3
v=2: dp[2] += 3-2 = 1, p=1: mxdep[1]=max(0,4)=4
cost = min(0+1=1, 4-0+1=5) = 1
mxcost[1]=max(1,1)=1, sumcost[1]=1+1=2
v=1: dp[1] += 2-1 = 1, dp[1]=6
答案 = 6 ✓
【七、复杂度】
- 时间:O(n),每个节点只处理一次。
- 空间:O(n),用于存储父亲、深度、dp 等数组。
- 总复杂度:O(Σn) ≤ O(5 × 10^6),可以过。
【八、注意事项】
- f_i < i,所以倒序遍历即为后序遍历。
- dp[v] 先加上 sumcost[v] - mxcost[v],因为 mxcost[v] 对应的路径可以继续往上走。
- cost = min(从根重新派兵, 走进子树再退回)。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
static const int MAXN = 1000000 + 5;
static int par_[MAXN], dep[MAXN]; // 父亲数组、深度数组
static long long dp[MAXN], mxdep[MAXN], mxcost[MAXN], sumcost[MAXN];
// dp[u] : 占领以 u 为根的子树所需的最小总步数
// mxdep[u] : 以 u 为根的子树中,最深叶子节点的深度
// mxcost[u] : 以 u 为根的子树中,单条路径的最大代价
// sumcost[u] : 以 u 为根的子树中,所有路径的代价之和
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
for (int cas = 1; cas <= T; ++cas) {
int n;
cin >> n;
// 初始化根节点 1
dep[1] = 0; // 根节点深度为 0
dp[1] = 0; // 根节点自身不需要步数
mxdep[1] = 0; // 根节点子树最深深度初始为 0
mxcost[1] = 1; // 单条路径最大代价初始为 1(根节点自身)
sumcost[1] = 1; // 总代价初始为 1
// 读入 n-1 个父亲,建树
for (int i = 2; i <= n; ++i) {
int f;
cin >> f;
par_[i] = f; // i 的父亲是 f
dep[i] = dep[f] + 1; // 深度 = 父亲深度 + 1
dp[i] = 0; // 初始 dp 为 0
mxdep[i] = dep[i]; // 子树最深深度初始为自身深度
mxcost[i] = 1; // 单条路径最大代价初始为 1
sumcost[i] = 1; // 总代价初始为 1
}
// f_i < i,所以倒序遍历即为后序遍历:儿子先于父亲处理
for (int v = n; v >= 1; --v) {
// 此时 v 的所有儿子都已经处理完毕
// dp[v] 加上所有儿子路径的代价(除去最大代价那条,因为它可以继续往上走)
dp[v] += sumcost[v] - mxcost[v];
if (v == 1) break; // 根节点处理完毕,退出
int p = par_[v]; // v 的父亲 p
// 更新父亲 p 的子树最深深度
if (mxdep[v] > mxdep[p]) mxdep[p] = mxdep[v];
// 把 v 的 dp 值累加到父亲 p
dp[p] += dp[v];
// 两种方案:
// 1) 从根重新派一支军队到 v:代价 = dep[p] + 1
// (从根走到 p,再走一步到 v)
long long cost = (long long)dep[p] + 1;
// 2) 让当前军队走进 v 的子树再退回:代价 = mxdep[v] - dep[p] + 1
// (从 p 走到 v 子树最深叶子,再退回一步)
long long alt = mxdep[v] - dep[p] + 1;
// 取较小值
if (alt < cost) cost = alt;
// 更新父亲 p 的单条路径最大代价
if (cost > mxcost[p]) mxcost[p] = cost;
// 累加父亲 p 的总代价
sumcost[p] += cost;
}
// 输出答案:dp[1] 就是占领整棵树的最小总步数
cout << "Case #" << cas << ": " << dp[1] << '\n';
}
return 0;
}
第46届ICPC国际大学生程序设计竞赛沈阳站L---树形DP+容斥定理
- 题目描述:第46届ICPC国际大学生程序设计竞赛沈阳站L
- 题目描述和思路如下:
text
Perfect Matchings(2021 ICPC 沈阳 L / 牛客 56483 L)
【题目描述】
AAA 得到了一个包含 2n 个顶点的完全图,任意两个不同顶点之间都有一条边。
但他觉得完全图不够漂亮,于是删掉了 2n-1 条边,这些边恰好构成一棵树。
现在他想知道,剩下的图中不同完美匹配的数量。
完美匹配是指 n 条边的集合,且任意两条边不共享顶点。
答案对 998244353 取模。
【输入】
第一行:n(2 ≤ n ≤ 2000)
接下来 2n-1 行:每行两个整数 u, v,表示被删除的一条树边。
保证这些边构成一棵包含 2n 个顶点的树。
【输出】
一个整数,表示剩余图中完美匹配的数量对 998244353 取模。
【样例1】
输入:
2
1 2
1 3
3 4
输出:
1
【样例2】
输入:
3
1 2
2 3
3 4
4 5
5 6
输出:
5
【思路解说】
【一、正难则反 + 容斥原理】
直接求"删掉树边后剩下的完美匹配数"很困难。
我们反过来想:
- 完全图 K_{2n} 的完美匹配总数 = (2n-1)!!(双阶乘)
- 我们要求的是:不包含任何树边的完美匹配数
- 用容斥:总匹配数 - 至少包含 1 条树边的 + 至少包含 2 条树边的 - ...
设 S 是树边的子集,要求 S 中任意两条边不共端点(即 S 是一个匹配)。
那么:
答案 = Σ_{k=0}^{n} (-1)^k · f_k · (2(n-k)-1)!!
其中:
- f_k = 树中大小为 k 的匹配数(即选出 k 条互不共端点的树边的方案数)
- (2m-1)!! = 2m 个点的完美匹配数
- (-1)^k 是容斥系数
【二、树形背包 DP 求 f_k】
我们需要在树上求出:恰好选择 k 条边作为匹配边,且这些边互不共端点的方案数。
定义状态:
dp[u][j][0/1]:
- u:当前节点
- j:以 u 为根的子树中,选了 j 条树边作为匹配边
- 0/1:u 是否已经被匹配
转移时合并子树 v:
1) 不选边 (u, v):
dp[u][i+j][0] += dp[u][i][0] * (dp[v][j][0] + dp[v][j][1])
dp[u][i+j][1] += dp[u][i][1] * (dp[v][j][0] + dp[v][j][1])
2) 选边 (u, v)(要求 u 和 v 都未匹配):
dp[u][i+j+1][1] += dp[u][i][0] * dp[v][j][0]
最终 f_k = dp[1][k][0] + dp[1][k][1]。
【三、双阶乘预处理】
(2m-1)!! = 1 × 3 × 5 × ... × (2m-1)
可以用递推预处理:
df[0] = 1
df[m] = df[m-1] × (2m-1) % MOD
【四、算法流程】
1) 读入 n 和 2n-1 条树边,建树。
2) 树形背包 DP,计算 dp[u][j][0/1]。
3) 得到 f[k] = dp[1][k][0] + dp[1][k][1]。
4) 预处理双阶乘 df[m]。
5) 容斥求和:
ans = Σ_{k=0}^{n} (-1)^k · f[k] · df[n-k]
6) 输出 ans。
【五、样例验证】
样例1:n=2,树边 (1,2), (1,3), (3,4)
树形 DP 得到 f[0]=1, f[1]=3, f[2]=1
df[2]=3, df[1]=1, df[0]=1
ans = 1×3 - 3×1 + 1×1 = 1 ✓
样例2:n=3,树是一条链 1-2-3-4-5-6
树形 DP 得到 f[0]=1, f[1]=5, f[2]=6, f[3]=1
df[3]=15, df[2]=3, df[1]=1, df[0]=1
ans = 1×15 - 5×3 + 6×1 - 1×1 = 5 ✓
【六、复杂度】
- 树形背包 DP:O(n²)
- 容斥求和:O(n)
- 总复杂度:O(n²),n ≤ 2000,可以过。
【七、注意事项】
- 树形背包 DP 合并时用临时数组,避免原地更新。
- dp 数组用 long long,因为中间结果可能很大。
- 容斥时注意符号,减法后要 +MOD 再 %MOD。
- 双阶乘要预处理到 n。
- 多组测试要清空 g 和 dp 数组。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MOD = 998244353;
const int MAXN = 4005; // 2n 最大 4000,开 4005 足够
int n, total;
vector<int> g[MAXN];
// dp[u][j][0/1]:
// u:当前节点
// j:以 u 为根的子树中,选了 j 条树边作为匹配边
// 0/1:u 是否已经被匹配
ll dp[MAXN][MAXN][2];
int sz[MAXN]; // 子树大小
ll fac[MAXN], inv_fac[MAXN];
// 快速幂
ll qpow(ll a, ll b) {
ll res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
// 预处理阶乘和阶乘逆元
void init() {
fac[0] = 1;
for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
inv_fac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
for (int i = MAXN - 2; i >= 0; i--) inv_fac[i] = inv_fac[i + 1] * (i + 1) % MOD;
}
// 树形背包 DP
void dfs(int u, int fa) {
sz[u] = 1;
dp[u][0][0] = 1; // u 未匹配,选了 0 条边
dp[u][0][1] = 0; // u 已匹配但选了 0 条边,不可能
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u); // 递归处理子树 v
// 临时数组,用于合并 u 和 v 的子树
vector<vector<ll>> tmp(sz[u] + sz[v] + 1, vector<ll>(2, 0));
for (int i = 0; i <= sz[u]; i++) {
for (int j = 0; j <= sz[v]; j++) {
// 情况 1:不选边 (u, v)
// u 的状态 a 和 v 的状态 b 可以任意组合
for (int a = 0; a < 2; a++) {
for (int b = 0; b < 2; b++) {
tmp[i + j][a] = (tmp[i + j][a] + dp[u][i][a] * dp[v][j][b]) % MOD;
}
}
// 情况 2:选边 (u, v) 作为匹配边
// 要求 u 和 v 都未匹配(状态都是 0)
// 选完后,u 变成已匹配(状态 1),匹配边数 +1
if (i + j + 1 <= sz[u] + sz[v]) {
tmp[i + j + 1][1] = (tmp[i + j + 1][1] + dp[u][i][0] * dp[v][j][0]) % MOD;
}
}
}
sz[u] += sz[v];
// 把临时数组的值写回 dp[u]
for (int i = 0; i <= sz[u]; i++) {
dp[u][i][0] = tmp[i][0];
dp[u][i][1] = tmp[i][1];
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
init();
cin >> n;
total = 2 * n; // 总顶点数
// 读入 2n-1 条树边,建树
for (int i = 0; i < total - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
// 从根节点 1 开始树形 DP
dfs(1, 0);
// f[k] = 树中大小为 k 的匹配数
vector<ll> f(n + 1, 0);
for (int k = 0; k <= n; k++) {
f[k] = (dp[1][k][0] + dp[1][k][1]) % MOD;
}
// 双阶乘 (2m-1)!!:2m 个点的完美匹配数
// df[0] = 1
// df[m] = df[m-1] * (2m-1)
vector<ll> df(n + 1, 1);
for (int m = 1; m <= n; m++) {
df[m] = df[m - 1] * (2 * m - 1) % MOD;
}
// 容斥求和:
// ans = Σ_{k=0}^{n} (-1)^k · f[k] · df[n-k]
ll ans = 0;
for (int k = 0; k <= n; k++) {
ll term = f[k] * df[n - k] % MOD;
if (k & 1) ans = (ans - term + MOD) % MOD; // 奇数项减
else ans = (ans + term) % MOD; // 偶数项加
}
cout << ans << '\n';
return 0;
}
2021年中国大学生程序设计竞赛CCPC哈尔滨站G---状压DP+最短路
- 题目链接:2021年中国大学生程序设计竞赛CCPC哈尔滨站G
- 题目描述及思路如下:
text
Damaged Bicycle(2021 CCPC 哈尔滨站 G / CF Gym 103447 G)
【题目描述】
George 要从宿舍(顶点 1)到教学楼(顶点 n)。
校园可以建模为一个 n 个顶点、m 条无向边的图,第 i 条边长度为 w_i 米。
George 步行速度 t 米/秒,骑车速度 r 米/秒。
校园里有 k 辆共享单车,第 i 辆在顶点 a_i,有 p_i% 的概率是坏的。
只有到达顶点 a_i 并扫码后,才能知道这辆车是否损坏。
一旦确认某辆车是好的,George 会立刻骑上它,直到到达顶点 n 才下车。
George 想用最优策略最小化到达教学楼的时间期望。
如果无法到达顶点 n,输出 -1。
【输入】
第一行:t, r(1 ≤ t ≤ r ≤ 10^4)------步行和骑车速度
第二行:n, m(1 ≤ n, m ≤ 10^5)------顶点数和边数
接下来 m 行:u_i, v_i, w_i(1 ≤ w_i ≤ 10^4)------无向边
接下来一行:k(0 ≤ k ≤ 18)------单车数量
接下来 k 行:a_i, p_i(1 ≤ a_i ≤ n,0 ≤ p_i ≤ 100)------单车位置和损坏概率
保证每辆单车在不同顶点。
【输出】
如果无法到达顶点 n,输出 -1。
否则输出一个实数,表示最小时间期望,误差 10^-6 以内。
【样例1】
输入:
3 15
4 3
1 2 600
1 3 300
2 4 900
1
3 50
输出:
460.000000
【样例2】
输入:
3 15
5 4
1 2 600
1 3 300
2 5 900
3 4 3
2
3 50
4 0
输出:
220.600000
【样例3】
输入:
3 15
5 4
1 2 600
1 3 300
4 5 900
3 2 300
2
3 50
4 0
输出:
-1
【思路解说】
【一、问题本质:状压 DP + 最短路】
k ≤ 18,所以可以用状态压缩 DP 来表示"已经尝试过哪些单车"。
**关键观察:**
- 一旦骑上好的单车,就会直接骑到终点 n,不再下车。
- 如果某辆单车是坏的,George 只能继续步行去尝试其他单车。
- 因此,George 的策略是:决定尝试单车的顺序(或决定放弃某些单车)。
【二、预处理:最短路】
首先需要计算关键点之间的最短距离:
- 起点 1、终点 n、所有单车位置 a_i。
由于 n, m ≤ 10^5,需要用 Dijkstra 算法。
对每个关键点跑一次 Dijkstra,得到它到其他所有点的最短距离。
**关键距离:**
- d1[a_j]:从起点 1 步行到单车 j 的距离。
- db[i][a_j]:从单车 i 步行到单车 j 的距离。
- db[i][n]:从单车 i 步行到终点 n 的距离。
- d1[n]:从起点 1 直接步行到终点 n 的距离。
**时间计算:**
- 步行时间 = 距离 / t
- 骑车时间 = 距离 / r
【三、状压 DP】
设 `dp[mask][last]` 表示:
- 已经尝试过的单车集合为 mask(二进制表示)
- 当前位于第 last 辆单车的位置 a_last
- 此时到达终点的最小时间期望。
**转移:**
- 直接步行到终点:
`dp[mask][last] = dist(a_last, n) / t`
- 尝试一辆未尝试过的单车 j(j 不在 mask 中):
`dp[mask][last] = min(dp[mask][last],
dist(a_last, a_j) / t
+ p_j/100 * dp[mask | (1 << j)][j]
+ (1 - p_j/100) * dist(a_j, n) / r)`
其中:
- `dist(a_last, a_j) / t`:从当前位置步行到单车 j 的时间
- `p_j/100`:单车 j 损坏的概率
- `dp[mask | (1 << j)][j]`:单车 j 损坏后,继续尝试其他单车的期望时间
- `(1 - p_j/100) * dist(a_j, n) / r`:单车 j 好的情况下,骑车到终点的时间
**最终答案:**
- 直接从起点 1 步行到终点 n:`d1[n] / t`
- 或者先尝试某辆单车 j:
`d1[a_j] / t + p_j/100 * dp[1 << j][j] + (1 - p_j/100) * dist(a_j, n) / r`
- 取最小值。
【四、算法流程】
1) 读入 t, r, n, m 和所有边。
2) 读入 k 和所有单车信息。
3) 对起点 1 跑 Dijkstra,得到 d1。
4) 如果 d1[n] 不可达,输出 -1。
5) 对每辆单车 a_i 跑 Dijkstra,得到 db[i]。
6) 状压 DP:
- 从 mask = (1<<k)-1 到 1 倒序枚举。
- 对于每个 mask 和 last(last 在 mask 中):
- 初始值 = db[last][n] / t(直接步行到终点)。
- 枚举未尝试的单车 j:
- 如果 db[last][a_j] 不可达,跳过。
- 计算 val = db[last][a_j] / t + s[j] * db[j][n] / r + q[j] * dp[mask | (1<<j)][j]
- 更新最小值。
7) 计算最终答案:
- ans = d1[n] / t
- 对于每辆单车 j:
- 如果 d1[a_j] 不可达,跳过。
- val = d1[a_j] / t + s[j] * db[j][n] / r + q[j] * dp[1<<j][j]
- 更新 ans。
8) 输出 ans,保留 6 位小数。
【五、复杂度】
- Dijkstra:O((k+2) × (n + m) log n)
- 状压 DP:O(2^k × k²)
- 总复杂度:O((k+2) × (n + m) log n + 2^k × k²)
- k ≤ 18,2^18 = 262144,可以过。
【六、注意事项】
- 如果起点 1 到终点 n 不可达,输出 -1。
- 单车损坏概率 p_i 是百分比,要除以 100。
- 骑车时间 = 距离 / r,步行时间 = 距离 / t。
- DP 时注意浮点数精度,用 double。
- 如果某辆单车是好的,骑到终点的时间 = dist(a_i, n) / r。
- 如果所有单车都坏了,George 只能步行到终点。
- dp 数组初始化为 INF。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> pli;
static const ll INF = (ll)4e18;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t, r; // t: 步行速度 (m/s), r: 骑车速度 (m/s)
cin >> t >> r;
int n, m;
cin >> n >> m;
// 邻接表建图
vector<vector<pair<int,int>>> adj(n + 1);
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w});
adj[v].push_back({u, w});
}
int k;
cin >> k; // 单车数量
vector<int> a(k); // a[i]: 第 i 辆单车的位置
vector<double> q(k), s(k); // q[i]: 损坏概率, s[i]: 好的概率
for (int i = 0; i < k; ++i) {
int p;
cin >> a[i] >> p;
q[i] = p / 100.0;
s[i] = 1.0 - q[i];
}
// Dijkstra 求单源最短路
auto dijkstra = [&](int src) {
vector<ll> dist(n + 1, INF);
priority_queue<pli, vector<pli>, greater<pli>> pq;
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
pli cur = pq.top(); pq.pop();
ll d = cur.first; int u = cur.second;
if (d > dist[u]) continue; // 过期节点跳过
for (size_t e = 0; e < adj[u].size(); ++e) {
int v = adj[u][e].first, w = adj[u][e].second;
if (d + w < dist[v]) {
dist[v] = d + w;
pq.push({dist[v], v});
}
}
}
return dist;
};
// 从起点 1 跑 Dijkstra
vector<ll> d1 = dijkstra(1);
// 如果终点 n 不可达,直接输出 -1
if (d1[n] == INF) { cout << -1 << "\n"; return 0; }
// 对每辆单车的位置 a[i] 跑 Dijkstra
vector<vector<ll>> db(k);
for (int i = 0; i < k; ++i) db[i] = dijkstra(a[i]);
const double INFd = 1e18;
int full = 1 << k;
vector<double> dp;
if (k > 0) dp.assign((size_t)full * k, INFd);
// dp[mask][last] = 已经尝试过 mask 中的单车(它们都坏了),
// 当前站在第 last 辆单车的位置,
// 到达终点的最小时间期望。
for (int mask = full - 1; mask >= 1; --mask) {
double* row = dp.data() + (size_t)mask * k;
for (int last = 0; last < k; ++last) {
if (!((mask >> last) & 1)) continue; // last 必须在 mask 中
const vector<ll>& dl = db[last];
// 方案 1:直接步行到终点
double best = (dl[n] == INF) ? INFd : (double)dl[n] / t;
// 方案 2:尝试一辆未尝试过的单车 j
for (int j = 0; j < k; ++j) {
if ((mask >> j) & 1) continue; // j 已经尝试过
ll dij = dl[a[j]];
if (dij == INF) continue; // 不可达
double rideT = (db[j][n] == INF) ? INFd : (double)db[j][n] / r;
// 单车 j 坏了之后,继续尝试其他单车
double cont = dp[(size_t)(mask | (1 << j)) * k + j];
// 期望 = 走到 j 的时间 + 坏的概率 * 继续尝试 + 好的概率 * 骑车到终点
double val = (double)dij / t + s[j] * rideT + q[j] * cont;
if (val < best) best = val;
}
row[last] = best;
}
}
// 最终答案:从起点 1 出发的最优策略
double ans = (double)d1[n] / t; // 直接步行到终点
for (int j = 0; j < k; ++j) {
if (d1[a[j]] == INF) continue;
double rideT = (db[j][n] == INF) ? INFd : (double)db[j][n] / r;
double cont = dp[(size_t)(1 << j) * k + j];
double val = (double)d1[a[j]] / t + s[j] * rideT + q[j] * cont;
if (val < ans) ans = val;
}
cout << fixed << setprecision(6) << ans << "\n";
return 0;
}
字符串类算法区域赛题
2020年第六届全国大学生程序设计竞赛K---KMP前缀函数
- 题目链接:2020年第六届全国大学生程序设计竞赛K
- 题目描述及思路:
text
【题目描述】
对于字符串 u = u1 u2 ... un,定义:
- pre(u, i) = u1 u2 ... ui,即 u 的长度为 i 的前缀。
- suf(u, i) = u_{n-i+1} ... un,即 u 的长度为 i 的后缀。
- 特别地,pre(u, 0) 和 suf(u, 0) 都是空串。
- u + v 表示字符串 u 和 v 的拼接。
- presuf(u, v) = max{ i | i < |u| 且 i ≤ |v| 且 pre(u, i) = suf(v, i) }。
给定两个字符串 s = s1 s2 ... sn 和 t = t1 t2 ... tm。
定义 f(i) = presuf(s, pre(s, i) + t),其中 0 ≤ i ≤ n-1。
要求输出 f(0), f(1), ..., f(n-1)。
【输入】
输入包含多组测试数据,以 EOF 结束。每组数据:
- 第一行:字符串 s
- 第二行:字符串 t
- 1 ≤ n, m ≤ 10^6
- 字符集为小写字母 a ~ z
- 所有输入的 max(n, m) 之和 ≤ 10^6
【输出】
对于每组数据,输出 n 个整数,表示 f(0), f(1), ..., f(n-1)。
【样例1】
输入:
aaa
a
ababa
a
ab
cd
输出:
1 2 2
1 1 3 1 3
0 0
【样例说明】
第二组:s = ababa, t = a
f(4) = presuf(s, pre(s, 4) + t) = presuf(ababa, abab + a) = presuf(ababa, ababa)
pre(ababa, 3) = aba, suf(ababa, 3) = aba,匹配长度为 3。
所以 f(4) = 3。
【思路解说】
【一、问题转化】
我们要对每个 i 求:
f(i) = presuf(s, pre(s, i) + t)
= max{ k | k < n, k ≤ i + m, pre(s, k) = suf(pre(s, i) + t, k) }
也就是说,对于字符串 pre(s, i) + t,它的后缀与 s 的前缀匹配的最长长度是多少(且匹配长度必须小于 n)。
【二、匹配长度 k 的分类】
把匹配长度 k 分成两类:
1) k ≤ m:匹配的后缀完全落在 t 中。
此时 pre(s, k) = suf(t, k),与 i 无关。
设 b0 = 最长的「s 的前缀等于 t 的后缀」的长度。
则这类匹配的最大值就是 b0。
2) k > m:匹配的后缀跨越了 pre(s, i) 和 t 的边界。
此时需要满足:
- t = s[k-m+1..k](即 t 出现在 s 中以 k-m+1 开头、长度为 m 的位置)
- k - m 在 i 的失配链上(即 k-m 是 pre(s, i) 的某个 border)
设 val[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置时。
则这类匹配的最大值就是「在 i 的失配链上,val 的最大值」。
【三、预处理】
1) 计算 s 的前缀函数 piS。
2) 计算 matchEnd[j]:当 t 出现在 s[j+1..j+m] 位置时,记录结束位置 j+m。
做法:构造字符串 t + '\1' + s,求前缀函数,找到所有 pi 值为 m 的位置。
3) 计算 overlap = 最长的「s 的前缀等于 t 的后缀」的长度。
做法:构造字符串 s + '\1' + t,求前缀函数,最后一个值就是 overlap。
4) 计算 best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值。
递推:best[i] = max(matchEnd[i], best[piS[i]])。
【四、答案计算】
对于每个 i:
q = max(overlap, best[i])
ans = (q < n) ? q : piS[n]
其中 piS[n] 是 s 的最长真 border(因为匹配长度必须小于 n)。
【五、复杂度】
- 预处理前缀函数:O(n + m)
- 计算 matchEnd:O(n + m)
- 计算 best:O(n)
- 输出答案:O(n)
- 总复杂度:O(n + m),可以过。
【六、注意事项】
- 多组测试数据,以 EOF 结束。
- 前缀函数的下标从 0 开始,pi[k] 表示 x[0..k-1] 的最长真 border。
- 构造 t + '\1' + s 时,分隔符 '\1' 不能出现在原字符串中。
- 匹配长度必须小于 n,所以当 q == n 时要取 piS[n]。
- 所有输入的 max(n, m) 之和 ≤ 10^6,所以 O(n + m) 可以过。
- 答案之间用空格分隔,每组数据输出一行。
- 根据以上思路代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// 前缀函数:pi[k] = x[0..k-1] 的最长真 border
// pi[0] = 0, pi[k] 表示 x 的前 k 个字符的最长真前缀后缀长度
static vector<int> prefixFunction(const string& x) {
int L = (int)x.size();
vector<int> pi(L + 1, 0); // 多开一位,方便后面取 pi[L]
for (int i = 1; i < L; i++) {
int j = pi[i]; // 从前一个位置的最长 border 开始尝试
while (j > 0 && x[i] != x[j]) j = pi[j]; // 失配时跳 border
if (x[i] == x[j]) j++; // 匹配成功,border 长度加 1
pi[i + 1] = j; // 记录前 i+1 个字符的最长 border
}
return pi;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s, t, combined;
while (cin >> s >> t) { // 多组测试数据,以 EOF 结束
int n = (int)s.size();
int m = (int)t.size();
// s 的前缀函数
vector<int> piS = prefixFunction(s);
// matchEnd[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置(1-based)
// 即 s 中以 j 开头、长度为 m 的子串等于 t 时,记录其结束位置
vector<int> matchEnd(n + 1, 0);
combined.clear();
combined.reserve(m + 1 + n);
combined += t;
combined.push_back('\1'); // 分隔符,避免 t 和 s 之间产生额外的匹配
combined += s;
{
// 对 t + '\1' + s 求前缀函数
// 若某个位置的前缀函数值等于 m,说明 t 出现在该位置之前
vector<int> pi = prefixFunction(combined);
for (int i = m + 1; i < (int)combined.size(); i++) {
if (pi[i + 1] == m) { // 匹配到完整的 t
int endPos = i - m; // t 在 s 中的结束位置(1-based)
int startPos = endPos - m; // t 在 s 中的起始位置减一(1-based)
if (startPos >= 1) matchEnd[startPos] = endPos;
}
}
}
// overlap = 最长的「s 的前缀」等于「t 的后缀」的长度
// 构造 s + '\1' + t,求前缀函数,最后一个值就是 overlap
combined.clear();
combined.reserve(n + 1 + m);
combined += s;
combined.push_back('\1');
combined += t;
int overlap = prefixFunction(combined)[(int)combined.size()];
// best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值
// 即:从 i 开始不断跳 piS,取经过的所有位置的 matchEnd 最大值
vector<int> best(n + 1, 0);
best[0] = matchEnd[0];
for (int i = 1; i < n; i++) {
best[i] = max(matchEnd[i], best[piS[i]]);
}
// 输出答案
// 对于每个 i,q = max(overlap, best[i]) 就是 pre(s,i)+t 的后缀与 s 的前缀的最长匹配长度
// 但匹配长度必须小于 n,所以如果 q == n,要取 s 的最长真 border piS[n]
for (int i = 0; i < n; i++) {
int q = max(overlap, best[i]);
int ans = (q < n) ? q : piS[n];
cout << ans << (i + 1 < n ? ' ' : '\n');
}
}
return 0;
}
第七届ccpc威海站D---kmp前缀函数+二分
- 题目链接:第七届ccpc威海站D
- 题目描述及思路:
text
【题目描述】
给定一个长度为 n 的字符串 s(只含小写字母),以及 q 次查询。
每次查询给出一个整数 i,表示假设把 s 的第 i 个字符改成 '#'(注意:只是假设,
不会真的修改原串),问修改后的新字符串有多少个周期。
周期的定义:整数 T 是字符串 s 的周期,当且仅当:
- 1 ≤ T < |s|
- 对于所有 i ∈ (T, |s|],都有 s[i] = s[i - T]
【输入】
第一行:字符串 s(1 ≤ |s| ≤ 10^6)
第二行:整数 q(1 ≤ q ≤ 10^6)
接下来 q 行:每行一个整数 i(1 ≤ i ≤ |s|)
【输出】
对于每次查询,输出一个整数,表示修改后的新字符串的周期个数。
【样例1】
输入:
ccpc
4
1
2
3
4
输出:
0
1
1
0
【样例解释】
原串 "ccpc" 的周期只有 T = 3(因为 n - T = 1,即长度为 1 的 border)。
- 查询 i=1:修改后为 "#cpc",周期 T=3 被破坏,答案 0。
- 查询 i=2:修改后为 "c#pc",周期 T=3 仍然成立,答案 1。
- 查询 i=3:修改后为 "cc#c",周期 T=3 仍然成立,答案 1。
- 查询 i=4:修改后为 "ccp#",周期 T=3 被破坏,答案 0。
【思路解说】
【一、原串的周期与 border 的关系】
一个字符串 s 的周期 T 与它的 border(真前缀等于真后缀)有对应关系:
T 是 s 的周期 ⇔ n - T 是 s 的 border。
所以,求 s 的所有周期,等价于求 s 的所有 border 长度 b,然后 T = n - b。
用 KMP 前缀函数 pi 可以求出所有 border:
- pi[n-1] 是最长 border 长度。
- 不断跳 pi 可以得到所有 border 长度。
【二、修改一个字符对周期的影响】
把第 i 个字符改成 '#' 后,周期 T 是否仍然成立?
周期 T 要求:对于所有 j ∈ (T, n],都有 s'[j] = s'[j-T]。
修改后,只有位置 i 变成了 '#'。所以:
- 如果 j = i 且 i > T,那么需要 s'[i] = s'[i-T],
即 '#' = s'[i-T]。但 '#' 不是小写字母,所以这个等式不成立。
因此,只要 i > T,周期 T 就被破坏。
- 如果 j = i + T 且 i + T ≤ n,那么需要 s'[i+T] = s'[i],
即 s[i+T] = '#'。这也不成立。
因此,只要 i + T ≤ n,周期 T 就被破坏。
所以,周期 T 被破坏,当且仅当:
i > T 或 i + T ≤ n。
【三、周期 T 仍然成立的条件】
周期 T 不被破坏,当且仅当:
i ≤ T 且 i + T > n。
即:
T ≥ i 且 T > n - i。
合并得:
T > max(i - 1, n - i)。
所以,对于查询 i,答案就是:原串的周期集合中,满足
T > max(i - 1, n - i)
的周期个数。
【四、算法流程】
1) 用 KMP 求 s 的前缀函数 pi。
2) 从 pi[n-1] 开始不断跳 pi,收集所有 border 长度 b,
则周期 T = n - b。把所有周期存入数组 periods。
3) 对 periods 排序。
4) 对于每个查询 i:
- 计算 threshold = max(i - 1, n - i)。
- 用二分查找(upper_bound)统计 periods 中 > threshold 的个数。
- 输出这个个数。
【五、复杂度】
- 求前缀函数:O(n)
- 收集周期:O(n)
- 排序:O(n log n)
- 每次查询:O(log n)
- 总复杂度:O(n log n + q log n),可以过。
【六、注意事项】
- 周期 T 必须满足 1 ≤ T < n。
- 收集 border 时,从 pi[n-1] 开始不断跳 pi,直到 0。
- 查询时用 upper_bound 找第一个 > threshold 的位置。
- 答案可能为 0,表示修改后没有周期。
- 多组测试要清空数组。
- 字符串长度可达 10^6,要用 O(n) 的 KMP。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// 求前缀函数 pi
// pi[i] = s[0..i] 的最长相等真前缀后缀长度
vector<int> get_pi(const string &s) {
int n = s.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++) {
int j = pi[i - 1]; // 从前一个位置的最长 border 开始尝试
while (j > 0 && s[i] != s[j]) j = pi[j - 1]; // 失配时跳 border
if (s[i] == s[j]) j++; // 匹配成功,border 长度加 1
pi[i] = j;
}
return pi;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
int n = s.size();
// 求前缀函数
vector<int> pi = get_pi(s);
// 收集所有周期 T = n - border
// border 是 s 的真前缀等于真后缀的长度
// 周期 T 与 border b 的关系:T = n - b
vector<int> periods;
int j = pi[n - 1]; // 最长 border
while (j > 0) {
periods.push_back(n - j); // 对应周期 T = n - j
j = pi[j - 1]; // 跳到下一个 border
}
sort(periods.begin(), periods.end()); // 排序,方便二分
int q;
cin >> q;
while (q--) {
int i;
cin >> i;
// 修改第 i 个字符后,周期 T 仍然成立的条件:
// i ≤ T 且 i + T > n
// 即 T > max(i - 1, n - i)
int threshold = max(i - 1, n - i);
// 统计 periods 中 > threshold 的个数
// upper_bound 返回第一个 > threshold 的位置
int ans = periods.end() - upper_bound(periods.begin(), periods.end(), threshold);
cout << ans << '\n';
}
return 0;
}
2025年ccpc重庆站K---KMP失配树
- 题目链接:2025年ccpc重庆站K
- 题目描述及思路:
text
Reverse KMP(QOJ 15429 / The 4th Universal Cup Extra Stage 4: Chongqing K)
【题目描述】
在 KMP 算法中,next 数组(也叫 LPS 数组,最长公共前后缀,或失配函数)是一个核心概念。
给定一个长度为 n 的字符串 S = s1s2...sn,它的 next 数组 next[1..n] 定义为:
next[i] 表示子串 S[1..i] 的最长相等真前缀和真后缀的长度。
特别地,next[1] = 0。
形式化地:
next[i] = max{ k | 0 ≤ k ≤ i-1, S[1..k] = S[i-k+1..i] }
给定一个长度为 n 的数组 A[1..n]。你需要统计有多少个长度为 n、定义在字符集 Σ 上的字符串 S,
使得它的 next 数组恰好等于给定的数组 A。字符集大小为 m。
答案对 998244353 取模。
【输入】
第一行:T(1 ≤ T ≤ 10^5),测试用例个数。
每个测试用例:
第一行:n, m(2 ≤ n ≤ 10^5,1 ≤ m ≤ 10^6)
第二行:n 个整数 A[1..n](0 ≤ A[i] ≤ i-1)
保证所有测试用例的 n 之和不超过 5 × 10^5。
【输出】
每个测试用例输出一行,一个整数,表示满足条件的字符串数量对 998244353 取模。
【样例1】
输入:
3
3 3
0 1 2
7 3
0 0 0 1 2 3 0
7 3
0 0 0 1 2 3 1
输出:
3
24
0
【思路解说】
【一、从 next 数组到字符约束】
给定 next 数组 A,我们要构造字符串 S,使得 S 的 next 数组恰好等于 A。
关键约束来自 next 数组的定义:
1) 如果 A[i] = j > 0,则 S[1..j] = S[i-j+1..i],即 S[i] = S[j](因为 S[1..j] 是 S[1..i] 的后缀)。
所以 S[i] 和 S[j] 必须相同。
2) 如果 A[i] = 0,则 S[i] 不能等于任何会导致更长匹配的字符。
具体来说,S[i] 不能等于失配链上任何已确定字符。
【二、合法性检查(必要条件)】
next 数组有一个重要性质:
next[i] ≤ next[i-1] + 1
如果给定的 A 不满足这个性质,则答案为 0。
**启发式:** 这个性质是 KMP 算法正确性的基础。如果 next[i] 增长得太快,
说明它"跳过了"某些必须匹配的前缀,这在真实字符串中是不可能的。
【三、失配树与等价类】
根据 next 数组,可以建立一棵**失配树**:
- 每个节点 i 的父亲是 next[i]。
- 根节点是 0。
在失配树中,如果 next[i] = j > 0,则 S[i] 和 S[j] 必须相同。
所以我们可以用**并查集**维护字符的等价类:
- 初始时每个位置自成一个等价类。
- 对于每个 i,如果 next[i] = j > 0,则把 i 和 j 合并到同一个等价类。
合并完成后,每个等价类代表一个"字符"。同一个等价类中的位置必须填相同的字符。
**启发式:** 这里的等价类合并是"必须相同"的约束。
但要注意,等价类合并的顺序会影响最终的代表元,所以我们需要用失配树的根
作为等价类的代表元。
【四、计数:g 数组的定义】
设 g[p] 表示位置 1..p+1 中已经锁定的不同字符类数。
**为什么是 1..p+1?**
因为当我们考虑 next[i] = 0 时,S[i] 必须是"全新字符",
它不能等于任何已经出现在前缀中的字符。
而前缀中最长的可能匹配长度是 p = next[i-1],
所以我们需要排除的字符是出现在位置 1..p+1 中的字符。
**g 数组的递推:**
g[0] = 1(位置 1 已经锁定了一个字符)
g[p] = g[fail[p]] + (p+1 <= n && fail[p+1] == 0 ? 1 : 0)
**启发式:** g[p] 统计的是"在位置 1..p+1 中,有多少个不同的字符类"。
当 next[p+1] = 0 时,说明位置 p+1 是一个"全新字符",所以字符类数 +1。
否则,位置 p+1 与之前的某个位置属于同一个字符类,字符类数不变。
【五、逐个位置计数】
初始化 ans = m(S[1] 有 m 种选择)。
对于 i 从 2 到 n:
p = fail[i-1](前一个位置的 next 值)
q = fail[i](当前位置的 next 值)
**情况 1:q == p + 1**
说明 S[i] = S[p+1],字符唯一确定,无额外选择。
**启发式:** 这是"直接扩展"的情况,next 值加 1,字符必须匹配。
**情况 2:q == 0**
说明 S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符。
选择数 = m - g[p]。
如果 ≤ 0,返回 0。
ans *= 选择数。
**启发式:** 这是"一路失配到 0"的情况,S[i] 必须是一个全新的字符。
**情况 3:1 ≤ q ≤ p**
说明失配若干步后匹配到 q。
需要验证合法性:
- q-1 必须在 p 的失配链上。
- 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同。
如果不满足,返回 0。
**启发式:** 这是"失配后部分匹配"的情况,需要确保不会产生更长的匹配。
【六、算法流程】
1) 读入 n, m 和数组 A。
2) 检查 A 的合法性:
- A[1] 必须为 0。
- 对于 i ≥ 2,A[i] ≤ A[i-1] + 1。
如果不满足,输出 0。
3) 建立失配树,计算每个节点的根(等价类代表)。
4) 计算 g[p]:位置 1..p+1 中已锁定的不同字符类数。
5) 初始化 ans = m(S[1] 有 m 种选择)。
6) 对于 i 从 2 到 n:
- p = A[i-1], q = A[i]。
- 根据 q 的值分三种情况处理。
7) 输出 ans % MOD。
【七、复杂度】
- 每个测试用例 O(n)。
- 总复杂度 O(Σn) ≤ 5 × 10^5,可以过。
【八、注意事项】
- next[1] 必须为 0。
- next[i] ≤ next[i-1] + 1。
- 用失配树和 g 数组来统计禁选字符数。
- 对于 q > 0 且 q ≤ p 的情况,需要验证失配链的合法性。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class ReverseKmp {
public:
static const long long MOD = 998244353LL;
void run() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n;
long long m;
cin >> n >> m;
A.resize(n);
for (int i = 0; i < n; i++) cin >> A[i];
cout << solveCase(n, m) << "\n";
}
}
private:
vector<int> A; // 输入的 next 数组(1-indexed 存储)
long long solveCase(int n, long long m) {
// fail[i] = A[i-1],1-indexed
fail.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fail[i] = A[i - 1];
// ===== 合法性检查 =====
// KMP 的 next 数组必须满足:
// next[1] = 0
// next[i] <= next[i-1] + 1
if (fail[1] != 0) return 0;
for (int i = 2; i <= n; i++) {
if (fail[i] > fail[i - 1] + 1) return 0;
}
// ===== 失配树:找每个位置的"根"(等价类代表) =====
// 如果 fail[x] == 0,说明 x 是一个新等价类的根
// 否则,x 和 fail[x] 属于同一个等价类,根是 root[fail[x]]
root.assign(n + 1, 0);
for (int x = 1; x <= n; x++) {
root[x] = (fail[x] == 0) ? x : root[fail[x]];
}
// ===== g[p] = 位置 1..p+1 中已锁定的不同字符类数 =====
// g[0] = 1:位置 1 已经锁定了一个字符类
// 对于 p >= 1:
// 如果 p+1 <= n 且 fail[p+1] == 0,说明位置 p+1 是一个新字符类,g[p] = g[fail[p]] + 1
// 否则,位置 p+1 和之前的某个位置属于同一类,g[p] = g[fail[p]]
g.assign(n + 1, 0);
g[0] = 1;
for (int p = 1; p <= n; p++) {
long long add = (p + 1 <= n && fail[p + 1] == 0) ? 1 : 0;
g[p] = g[fail[p]] + add;
}
// ===== 逐个位置计数 =====
long long ans = m % MOD; // S[1] 有 m 种选择
for (int i = 2; i <= n; i++) {
int p = fail[i - 1]; // 前一个位置的 next 值
int q = fail[i]; // 当前位置的 next 值
if (q == p + 1) {
// 情况 1:直接扩展
// S[i] = S[p+1],字符唯一确定,无额外选择
continue;
}
if (q == 0) {
// 情况 2:一路失配到 0
// S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符
// 选择数 = m - g[p]
long long f = m - g[p];
if (f <= 0) return 0;
ans = ans * (f % MOD) % MOD;
} else {
// 情况 3:失配若干步后匹配到 q(1 <= q <= p)
// 需要验证合法性:
// (a) q-1 必须在 p 的失配链上
// (b) 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同
// 验证 (a):q-1 是否在 p 的失配链上
bool ok = (q - 1 == 0);
int c = p;
while (!ok && c > 0) {
if (c == q - 1) { ok = true; break; }
c = fail[c];
}
if (!ok) return 0;
// 验证 (b):链上每个 S[c+1] 是否与 S[q] 属于同一等价类
int rq = root[q]; // S[i] 必须与 S[q] 属于同一等价类
c = p;
while (c >= q) {
if (root[c + 1] == rq) return 0; // 冲突:S[c+1] 与 S[q] 同类
c = fail[c];
}
}
}
return ans % MOD;
}
vector<int> fail; // next 数组(1-indexed)
vector<int> root; // 每个位置的等价类代表
vector<long long> g; // g[p] = 位置 1..p+1 中已锁定的不同字符类数
};
int main() {
ReverseKmp solver;
solver.run();
return 0;
}
第47届西安站G---字典树trie
- 题目链接: 第47届西安站G
- 题目描述及思路如下:
text
Perfect Word(2022 ICPC 西安区域赛 G / CF Gym 104077 G)
【题目描述】
给定 n 个字符串,要求找出最长的「完美单词」的长度。
一个字符串 t 被称为「完美单词」,当且仅当 t 的每一个非空子串都出现在给定的字符串中。
一个字符串 s 被称为 t 的子串,当且仅当 s 可以通过删除 t 的开头或结尾的若干个(可能为零个)字符得到。
【输入】
第一行包含一个整数 n(1 ≤ n ≤ 10^5)。
接下来 n 行,每行一个由小写英文字母组成的字符串。
保证所有给定字符串的总长度不超过 10^5。
【输出】
输出一个整数,表示最长的完美单词的长度。
【样例1】
输入:
4
a
t
b
ab
输出:
2
【样例解释】
字符串 "ab" 的子串有 "a", "b", "ab",它们都出现在给定字符串中,
所以 "ab" 是完美单词,长度为 2。没有更长的完美单词,输出 2。
【思路解说】
【一、核心引理】
一个字符串 t 是完美单词,当且仅当:
1) t 本身出现在给定字符串中;
2) |t| = 1,或者 t 去掉最后一个字符的前缀 t[1..L-1] 是完美单词,
并且 t 去掉第一个字符的后缀 t[2..L] 是完美单词。
**证明思路:**
- 必要性:如果 t 是完美单词,那么 t 的所有子串都出现在给定字符串中。
特别地,t[1..L-1] 和 t[2..L] 都是 t 的子串,所以它们也必须是完美单词。
- 充分性:如果 t[1..L-1] 和 t[2..L] 都是完美单词,那么它们的所有子串都出现在给定字符串中。
而 t 的任意真子串要么是 t[1..L-1] 的子串,要么是 t[2..L] 的子串(或两者都是)。
所以 t 的所有真子串都出现在给定字符串中,再加上 t 本身也出现,故 t 是完美单词。
【二、算法设计】
根据引理,我们可以按字符串长度从小到大进行 DP:
- 用正串 Trie 判断前缀 t[1..L-1] 是否存在且完美。
- 用反串 Trie 判断后缀 t[2..L] 是否存在且完美。
具体步骤:
1) 把所有给定字符串插入正串 Trie(F),并记录每个字符串的结束节点。
2) 把所有给定字符串反转后插入反串 Trie(R),并记录每个字符串的结束节点。
3) 把所有字符串按长度从小到大排序。
4) 按顺序处理每个字符串:
- 如果长度为 1:只要它出现在给定字符串中,就是完美单词。
- 否则:检查它的前缀(在 F 中)和后缀(在 R 中)是否都是完美单词。
- 如果是,则标记该字符串为完美单词,并更新答案。
5) 输出最长的完美单词长度。
【三、复杂度】
- 建 Trie:O(总长度 × 26)
- 排序:O(n log n)
- DP:O(n)
- 总复杂度:O(总长度 × 26 + n log n),可以过。
【四、注意事项】
- 正串 Trie 的节点编号从 1 开始(1 为根),0 表示空。
- 反串 Trie 同理。
- 处理长度为 1 的字符串时,不需要检查前缀和后缀。
- 用 char 数组标记 perfect 状态,节省空间。
- 答案初始为 0,如果没有完美单词则输出 0。
- 多组测试要清空 Trie 和 perfect 数组。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// Trie 字典树结构体
struct Trie {
vector<array<int, 26>> ch; // ch[u][c] 表示节点 u 走字符 c 到达的儿子编号
// 1 为根,0 表示空;节点编号 1..cnt
vector<int> par; // par[u] = u 的父亲节点编号
// 构造函数:初始化根节点
Trie() {
ch.resize(2); // 分配根节点(编号 1)和空节点(编号 0)
for (auto& a : ch) a.fill(0); // 所有儿子初始化为 0
par.assign(2, 0); // 根的父亲为 0
}
int size() const { return (int)ch.size(); }
// 插入字符串 s,返回结束节点编号到 endNode
void add(const string& s, int& endNode) {
int u = 1; // 从根节点 1 开始
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) { // 如果儿子不存在,新建节点
ch[u][x] = (int)ch.size();
ch.emplace_back();
ch.back().fill(0);
par.push_back(u); // 记录父亲
}
u = ch[u][x]; // 走到儿子
}
endNode = u; // 返回结束节点编号
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<string> s(n);
for (int i = 0; i < n; i++) cin >> s[i];
Trie F, R; // F: 正串 Trie;R: 反串 Trie
vector<int> nf(n), nr(n); // nf[i]: s[i] 在 F 中的结束节点
// nr[i]: s[i] 的反串在 R 中的结束节点
for (int i = 0; i < n; i++) {
F.add(s[i], nf[i]); // 正串插入 F
string r(s[i]);
reverse(r.begin(), r.end());
R.add(r, nr[i]); // 反串插入 R
}
// 按字符串长度从小到大排序
vector<int> ord(n);
iota(ord.begin(), ord.end(), 0);
sort(ord.begin(), ord.end(), [&](int a, int b) {
return s[a].size() < s[b].size();
});
// perfectF[u]: F 中节点 u 代表的字符串是否是完美单词
// perfectR[u]: R 中节点 u 代表的字符串是否是完美单词
vector<char> perfectF(F.size(), 0), perfectR(R.size(), 0);
int ans = 0;
for (int idx : ord) {
int L = (int)s[idx].size();
int uf = nf[idx]; // 正串结束节点
int ur = nr[idx]; // 反串结束节点
bool ok;
if (L == 1) {
// 单字符:只要它出现在给定字符串中,就是完美单词
ok = true;
} else {
// 前缀 t[1..L-1] 在 F 中的节点 = uf 的父亲
int pp = F.par[uf];
// 后缀 t[2..L] 的反串在 R 中的节点 = ur 的父亲
int qq = R.par[ur];
// 前后缀都必须是完美单词
ok = perfectF[pp] && perfectR[qq];
}
if (ok) {
perfectF[uf] = 1; // 标记正串为完美单词
perfectR[ur] = 1; // 标记反串为完美单词
ans = max(ans, L); // 更新答案
}
}
cout << ans << '\n';
return 0;
}
2025ccpc哈尔滨站J---Manacher算法
-
题目链接 :2025ccpc哈尔滨站J
-
题目描述及核心思路:
text
【题目描述】
给定一个只由 'o'、'v'、'w' 三种字符组成的字符串 s。
这些字符在视觉上有"连字"效果:
- 两个连续的 'v' 看起来像一个 'w'。
- 连续的 'v' 和 'w' 会连成一条折线。
- 'o' 是一个圆,不产生尖角。
题目定义一个字符串是"好的",当且仅当它写在纸上时,
整个图形是左右镜像对称的(即视觉上是回文)。
你需要找到给定字符串 s 中最长的"好的"子串,并输出这个子串。
如果存在多个最长,输出任意一个即可。
【输入】
第一行:T,表示测试用例个数。
每个测试用例:
第一行:n,表示字符串长度。
第二行:一个长度为 n 的字符串 s(只含 'o'、'v'、'w')。
【输出】
每个测试用例输出一行,表示最长的"好的"子串。
【样例1】
输入:
3
8
wwovvvv
16
wwwoooooooooovwww
11
wwvovvovvvv
输出:
wwovvvv
oooooooo
vvvv
【思路解说】
【一、核心观察】
题目中的"视觉回文"不能直接在原串上判断,
因为 'w' 在视觉上相当于两个 'v',
所以需要把原串映射成一个"视觉序列":
- 'v' -> 1 个下尖角
- 'w' -> 2 个下尖角
- 'o' -> 0 个下尖角(或视为分隔符)
映射后的序列是一个整数序列(每个数表示该位置贡献的尖角数量)。
在这个序列上,一个子串是"好的"当且仅当它本身是回文的。
【二、映射与边界处理】
直接把 'w' 展开成两个 'v' 会丢失原串的下标信息,
所以需要记录每个视觉位置对应的原串下标(或原串字符的起始位置)。
具体做法:
1) 遍历原串,把 'w' 展开成两个 1,'v' 保持 1,'o' 视为 2(或 0)。
2) 用一个数组 bidx[] 记录每个视觉位置是否对应原串某个字符的起始位置,
这样在找到回文区间后,可以快速映射回原串区间。
3) bidx 的前缀和可以用来把视觉下标转换成原串下标。
【三、Manacher 求最长视觉回文】
在映射后的视觉序列 T 上跑 Manacher:
- 因为 T 中每个元素是 1 或 2,不一定是字符,
所以需要用"整数回文"的 Manacher 写法。
- 分别处理奇数长度回文和偶数长度回文。
- 每扩展一步,就用当前的回文区间 [a, b] 去更新答案:
检查 a 和 b 是否都是合法的原串边界(即 isGood(a) 和 isGood(b))。
若是,则用 bidx[b] - bidx[a] 作为原串中的长度,更新最优解。
【四、为什么不能直接在原串上跑 Manacher】
因为原串的 'w' 在视觉上是两个 'v',
直接对原串做 Manacher 会把 'w' 当成一个字符,
从而漏掉 'w' 和 'v' 之间的视觉对称关系。
必须先把 'w' 展开成两个视觉单位,再跑 Manacher。
【五、算法流程】
1) 读入字符串 s。
2) 构造视觉序列 T:
'v' -> 1
'w' -> 1, 1
'o' -> 2(或 0,视具体定义)
同时记录每个视觉位置是否对应原串的字符起始位置(bidx)。
3) 对 bidx 做前缀和,方便把视觉下标映射回原串下标。
4) 在 T 上跑 Manacher(奇回文 + 偶回文):
每次扩展回文半径时,检查当前区间 [a, b] 的两端是否都是原串边界。
若是,则用 bidx[b] - bidx[a] 更新最长长度。
5) 根据最优的视觉区间 [bestA, bestB],映射回原串区间 [st, st + sublen)。
6) 输出对应的子串。
【六、复杂度】
构造视觉序列:O(n)
Manacher:O(n)
映射回原串:O(1)
总复杂度:O(n),可以过。
【七、注意事项】
- 'w' 必须展开成两个视觉单位,不能当成一个字符处理。
- Manacher 过程中每次扩展都要检查边界合法性,
否则可能把视觉上对称但原串上不对齐的区间误判为答案。
- bidx 的前缀和用于快速计算原串长度,避免 substr 拷贝。
- 多测试用例时注意只清空用到的部分,不要 memset 整个大数组。
- 输出时用 cout.write 直接输出区间,避免额外拷贝。
-
基于以上思路代码如下:
cpp#include <bits/stdc++.h> using namespace std; // 视觉序列总长度上限:n 最大约 1e7,w 展开后最多翻倍 const int MAXTOK = 20000010; int T[MAXTOK]; // 视觉序列:v->1, w->1,1, o->2 int bidx[MAXTOK + 1]; // bidx[i] = 视觉序列前 i 个位置对应原串的字符数 int rad[MAXTOK]; // Manacher 回文半径数组 int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); int t; cin >> t; while (t--) { int n; string s; cin >> n >> s; // 计算视觉序列的总长度 N // 'w' 展开成两个 1,所以贡献 2;'v' 和 'o' 各贡献 1 int N = 0; for (char ch : s) { if (ch == 'w') N += 2; else N += 1; } // 只清空用到的部分,避免 memset 整个大数组 // bidx[0..N] 都需要置 0 for (int p = 0; p <= N; p++) bidx[p] = 0; // 构造视觉序列 T,同时标记原串的字符边界 int idx = 0; for (char ch : s) { if (ch == 'w') { T[idx++] = 1; // w 展开成两个视觉单位 1 T[idx++] = 1; } else if (ch == 'v') { T[idx++] = 1; // v 是一个视觉单位 1 } else // 'o' { T[idx++] = 2; // o 用 2 表示(与 1 区分开,避免误判) } // 每个原串字符结束后,标记一个边界 bidx[idx] = 1; } // bidx 前缀和:bidx[p] 表示视觉序列前 p 个位置对应原串多少个字符 for (int p = 1; p <= N; p++) bidx[p] += bidx[p - 1]; int best = 0, bestA = 0, bestB = 0; // 判断视觉位置 p 是否是原串的字符边界 // p == 0 是起点,或者 bidx[p] > bidx[p-1] 说明 p 处有原串字符结束 auto isGood = [&](int p) -> bool { return p == 0 || bidx[p] > bidx[p - 1]; }; // 尝试用视觉区间 [a, b) 更新答案 // 要求 a 和 b 都是原串的字符边界,这样映射回原串才是完整的字符 auto consider = [&](int a, int b) { if (a >= 0 && b <= N && isGood(a) && isGood(b)) { int len = bidx[b] - bidx[a]; // 原串中的长度 if (len > best) { best = len; bestA = a; bestB = b; } } }; // 先考虑长度为 1 的视觉区间(单个字符本身) for (int i = 0; i < N; i++) consider(i, i + 1); // ===== 奇数长度回文 Manacher ===== // 以 i 为中心,向两边扩展 { int l = 0, r = -1; for (int i = 0; i < N; i++) { // 利用对称点初始化半径 int k = (i > r) ? 1 : min(rad[l + r - i], r - i + 1); // 暴力扩展 while (i - k >= 0 && i + k < N && T[i - k] == T[i + k]) { k++; consider(i - k + 1, i + k); } rad[i] = k - 1; consider(i - k + 1, i + k); // 更新最右回文边界 if (i + rad[i] > r) { l = i - rad[i]; r = i + rad[i]; } } } // ===== 偶数长度回文 Manacher ===== // 以 i-1 和 i 之间为中心,向两边扩展 { int l = 0, r = -1; for (int i = 0; i < N; i++) { // 利用对称点初始化半径 int k = (i > r) ? 0 : min(rad[l + r - i + 1], r - i + 1); // 暴力扩展 while (i - k - 1 >= 0 && i + k < N && T[i - k - 1] == T[i + k]) { k++; consider(i - k, i + k); } rad[i] = k; consider(i - k, i + k); // 更新最右回文边界 if (i + rad[i] - 1 > r) { l = i - rad[i]; r = i + rad[i] - 1; } } } // 把最优的视觉区间 [bestA, bestB) 映射回原串区间 int st = bidx[bestA]; // 原串起始下标 int sublen = bidx[bestB] - st; // 原串子串长度 // 直接输出,避免 substr 拷贝 cout.write(&s[st], sublen); cout << '\n'; } return 0; }
数据结构类区域赛题
第46届ICPC澳门站G---DP+线段树
- 题目链接:第46届ICPC澳门站G
- 题目描述及思路:
text
Cyclic Buffer(ICPC 澳门 2021 G / 牛客 31454 G / 洛谷 P9662)
【题目描述】
有一个大小为 n 的循环缓冲区,前 k 个位置(位置 1 到 k)有"读者",可以访问。
初始时,缓冲区第 i 个位置上的整数是 a_i,且 a_1, a_2, ..., a_n 构成一个 1 到 n 的排列。
你需要按照 1, 2, ..., n 的顺序访问所有整数。一个整数只有在它位于前 k 个位置时才能被访问。
如果当前要访问的整数不在前 k 个位置,你可以将整个缓冲区向左或向右循环移动任意多次。
- 左移一次:第 i 个位置的数移到第 i-1 个位置(i > 1),第 1 个位置的数移到第 n 个位置。
- 右移一次:第 i 个位置的数移到第 i+1 个位置(i < n),第 n 个位置的数移到第 1 个位置。
问:最少需要移动多少次缓冲区,才能按顺序访问完所有整数?
【输入】
第一行:T,测试用例个数。
每个测试用例:
第一行:n, k(1 ≤ k ≤ n ≤ 10^6)
第二行:n 个整数 a_1, ..., a_n,构成 1 到 n 的排列。
保证所有测试用例的 n 之和 ≤ 10^6。
【输出】
每个测试用例输出一行,一个整数,表示最少移动次数。
【样例1】
输入:
2
5 3
2 4 3 5 1
1 1
1
输出:
3
0
【思路解说】
【一、问题转化:窗口模型】
循环缓冲区的"前 k 个位置"可以看作一个长度为 k 的窗口。
设窗口的起始位置为 s(1 ≤ s ≤ n),则窗口覆盖原位置 s, s+1, ..., s+k-1(模 n 意义下)。
对于每个整数 v,它在原缓冲区中的位置是 pos[v]。
要让 v 进入窗口,窗口起始位置 s 必须满足:
s ∈ W_v = [pos[v] - k + 1, pos[v]](模 n 意义下)
也就是说,窗口的起始位置 s 必须落在某个区间内。
【二、访问顺序与移动代价】
我们要按 1, 2, ..., n 的顺序访问。
每次访问一个整数 v 后,窗口的起始位置 s 可能会变化(因为我们可以移动缓冲区)。
设访问完整数 i 后,窗口起始位置为 s_i。
访问下一个整数 i+1 时,如果 i+1 已经在窗口内,则不需要移动,s_{i+1} = s_i。
否则,需要把窗口移动到某个 s_{i+1} ∈ W_{i+1},移动代价为环上 s_i 到 s_{i+1} 的最短距离。
所以,问题转化为:在环上选择一系列窗口起始位置 s_1, s_2, ..., s_n,
使得 s_v ∈ W_v,且总移动代价 Σ dist(s_{v-1}, s_v) 最小。
【三、关键结论:最优解中窗口边界对齐】
官方题解的一个重要结论是:
最优解中,每次移动后,目标整数 v 会恰好落在窗口的第 1 位或第 k 位。
也就是说,对于每个整数 v,我们只需要考虑两种窗口位置:
- s = pos[v](v 在第 1 位)
- s = pos[v] - k + 1(v 在第 k 位)
所以,我们可以定义 DP 状态:
f[v][0]:访问完 v 后,v 在第 1 位的最小代价。
f[v][1]:访问完 v 后,v 在第 k 位的最小代价。
【四、连续访问的压缩:g 数组】
如果访问完 v 后,发现 v+1, v+2, ..., v+m 都已经在窗口内,
那么我们可以直接连续访问它们,不需要任何移动。
只有当遇到第一个不在窗口内的整数时,才需要移动。
定义:
g0[v]:从 v 在第 1 位的状态出发,能连续访问到的下一个不在窗口内的整数。
g1[v]:从 v 在第 k 位的状态出发,能连续访问到的下一个不在窗口内的整数。
如果 g0[v] 不存在,说明从 v 开始可以一直访问到 n,花费 0。
【五、g 数组的计算】
g0[v] 的计算:
- 当 v 在第 1 位时,窗口覆盖原位置 pos[v] 到 pos[v]+k-1。
- 我们需要找到最小的整数 u > v,使得 u 不在这个窗口内。
- 这等价于:在值域上,找到第一个不在窗口内的值。
- 可以用滑动窗口 + 值域线段树(求 >= x 的第一个缺失值)来 O(n log n) 计算。
g1[v] 的计算类似,只是窗口覆盖原位置 pos[v]-k+1 到 pos[v]。
【六、DP 转移】
对于每个整数 i,如果 f[i][0] 或 f[i][1] 有效:
- 令 v = g0[i](或 g1[i])。
- 如果 v == -1,说明可以一直访问到 n,用 f[i][st] 更新答案。
- 否则,需要把 v 移入窗口,计算移动代价:
- 从 i 的位置(第 1 位或第 k 位)移动到 v 的第 1 位或第 k 位。
- 环上距离用 min(|x-y|, n-|x-y|) 计算。
- 更新 f[v][0] 和 f[v][1]。
【七、算法流程】
1) 读入 n, k 和排列 a,计算每个值的位置 pos[v]。
2) 如果 n == k,直接输出 0(所有位置都是读者)。
3) 用滑动窗口 + 值域线段树计算 g0 和 g1 数组。
4) 找到初始窗口内能免费访问的最大前缀,确定第一个需要移动的整数 fir。
5) 初始化 f[fir][0] 和 f[fir][1]。
6) 按 i 从小到大遍历,用 g0/g1 转移 DP。
7) 输出最小代价。
【八、复杂度】
- 计算 g0, g1:O(n log n)
- DP 转移:O(n)
- 总复杂度:O(n log n),可以过。
【九、注意事项】
- 值域线段树维护的是"哪些值在窗口内",需要支持查询 >= x 的第一个缺失值。
- 滑动窗口移动时,要同时更新线段树(加入新值,移除旧值)。
- 环上距离要取 min(d, n-d)。
- 答案可能很大,要用 long long。
- 多组测试要清空线段树和数组。
- n == k 时直接输出 0。
- 初始窗口内能免费访问的前缀要正确处理。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 值域线段树:维护 0..n-1 中每个值是否存在(1 存在,0 缺失)
// 支持查询 >= pos 的第一个缺失值(即第一个 0)
struct SegTree {
int n, sz; // n: 值域大小,sz: 线段树大小(2 的幂)
vector<int> t; // 线段树数组,t[p] = 区间内最小值(0 表示有缺失,1 表示全存在)
void init(int n_) {
n = n_;
sz = 1;
while (sz < n) sz <<= 1; // 把 sz 扩展到 >= n 的最小 2 的幂
t.assign(2 * sz, 0); // 初始全部为 0(全部缺失)
}
// 把值 i 的状态设为 v(v=1 表示存在,v=0 表示缺失)
void setv(int i, int v) {
int p = i + sz; // 叶子节点
t[p] = v;
for (p >>= 1; p; p >>= 1) { // 向上更新
t[p] = min(t[p << 1], t[p << 1 | 1]);
}
}
// 从节点 p 出发,找到它下面最左边的 0(即最左的缺失值)
int descend(int p) {
while (p < sz) {
p = (t[p << 1] == 0) ? (p << 1) : (p << 1 | 1);
}
int idx = p - sz;
return idx < n ? idx : -1; // 超出值域范围返回 -1
}
// 查询 >= pos 的第一个缺失值(0-indexed),无则返回 -1
int firstZero(int pos) {
if (pos >= n) return -1;
int l = pos + sz, r = n + sz; // 查询区间 [pos, n-1]
int ln[64], rn[64], lc = 0, rc = 0;
// 把区间拆成线段树上的若干节点
while (l < r) {
if (l & 1) ln[lc++] = l++;
if (r & 1) rn[rc++] = --r;
l >>= 1; r >>= 1;
}
// 从左到右检查左边界节点
for (int i = 0; i < lc; i++) {
if (t[ln[i]] == 0) return descend(ln[i]);
}
// 从右到左检查右边界节点
for (int i = rc - 1; i >= 0; i--) {
if (t[rn[i]] == 0) return descend(rn[i]);
}
return -1;
}
};
int main() {
int T;
if (scanf("%d", &T) != 1) return 0;
SegTree SEG;
while (T--) {
int n, k;
scanf("%d %d", &n, &k);
vector<int> a(n + 1), pos(n + 1);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
pos[a[i]] = i; // pos[v] = 值 v 在缓冲区中的位置
}
// 如果 n == k,所有位置都是读者,不需要移动
if (n == k) {
printf("0\n");
continue;
}
// 把 a 复制一份接在后面,方便处理环形
vector<int> p(2 * n + 2);
for (int i = 1; i <= n; i++) p[i] = a[i];
for (int i = 1; i <= n; i++) p[i + n] = a[i];
// g0[v]: 从 v 在第 1 位的状态出发,能连续访问到的下一个不在窗口内的整数
// g1[v]: 从 v 在第 k 位的状态出发,能连续访问到的下一个不在窗口内的整数
vector<int> g0(n + 1, -1), g1(n + 1, -1);
// ---- 计算 g0 ----
// 当值 x 在第 1 位时,窗口覆盖原位置 pos[x] .. pos[x]+k-1
// 我们需要找到最小的值 u > x,使得 u 不在这个窗口内
// 等价于:在值域上找到第一个不在窗口内的值
SEG.init(n);
// 初始窗口:从 p[n] 开始,长度为 k 的窗口
// 这里 p[n] 是 a[n],窗口覆盖 a[n], a[1], ..., a[k-1]
for (int i = 1; i <= k; i++) {
SEG.setv(p[n + i - 1] - 1, 1); // 把窗口内的值标记为存在
}
{
// 当前窗口对应的值是 p[n](在第 1 位)
// 找 >= p[n] 的第一个缺失值
int idx = SEG.firstZero(p[n] - 1);
g0[p[n]] = (idx < 0 ? -1 : idx + 1); // idx+1 转回 1-indexed
}
// 窗口从右向左滑动
for (int i = n - 1; i >= 1; i--) {
// 窗口左移:加入 p[i],移除 p[i+k]
SEG.setv(p[i] - 1, 1); // 新值进入窗口
SEG.setv(p[i + k] - 1, 0); // 旧值离开窗口
int idx = SEG.firstZero(p[i] - 1);
g0[p[i]] = (idx < 0 ? -1 : idx + 1);
}
// ---- 计算 g1 ----
// 当值 x 在第 k 位时,窗口覆盖原位置 pos[x]-k+1 .. pos[x]
// 我们需要找到最小的值 u > x,使得 u 不在这个窗口内
SEG.init(n);
// 初始窗口:以 p[n+1] 结尾,长度为 k 的窗口
for (int i = 1; i <= k; i++) {
SEG.setv(p[n - i + 2] - 1, 1);
}
{
int idx = SEG.firstZero(p[n + 1] - 1);
g1[p[n + 1]] = (idx < 0 ? -1 : idx + 1);
}
// 窗口从左向右滑动
for (int i = n + 2; i <= 2 * n; i++) {
SEG.setv(p[i] - 1, 1); // 新值进入窗口
SEG.setv(p[i - k] - 1, 0); // 旧值离开窗口
int idx = SEG.firstZero(p[i] - 1);
g1[p[i]] = (idx < 0 ? -1 : idx + 1);
}
// 环上两点 x, y 的最短距离
auto mv = [&](int x, int y) {
int d = abs(x - y);
return min(d, n - d);
};
const ll INF = (ll)4e18;
// f[i][0]: 访问完 i 后,i 在第 1 位的最小代价
// f[i][1]: 访问完 i 后,i 在第 k 位的最小代价
vector<array<ll, 2>> f(n + 1, {(ll)INF, (ll)INF});
// 找到初始窗口内能免费访问的最大前缀
// 即找到第一个位置 > k 的值
int fir = 0;
for (int v = 1; v <= n; v++) {
if (pos[v] > k) {
fir = v;
break;
}
}
// 如果所有值都在前 k 个位置内,直接输出 0
if (fir == 0) {
printf("0\n");
continue;
}
// 初始化第一个需要移动的值
f[fir][0] = mv(pos[fir], 1); // 把 fir 移到第 1 位
f[fir][1] = mv(pos[fir], k); // 把 fir 移到第 k 位
ll ans = INF;
// DP 转移
for (int i = fir; i <= n; i++) {
for (int st = 0; st < 2; st++) {
if (f[i][st] >= INF) continue;
// 从状态 (i, st) 出发,找到下一个需要移动的值 v
int v = (st == 0) ? g0[i] : g1[i];
if (v < 0) {
// 没有下一个需要移动的值,说明可以一直访问到 n
ans = min(ans, f[i][st]);
continue;
}
// 计算从 i 当前位置到 v 的目标位置的环上距离
// i 当前在位置 st==0 ? 1 : k
// v 的目标位置是 v 在第 1 位或第 k 位
int tmp = (pos[v] + (st == 0 ? 1 : k) - pos[i]) % n;
if (tmp <= 0) tmp += n; // 规范到 1..n
f[v][0] = min(f[v][0], f[i][st] + mv(tmp, 1));
f[v][1] = min(f[v][1], f[i][st] + mv(tmp, k));
}
}
printf("%lld\n", ans);
}
return 0;
}
第45届国际大学生编程设计竞赛昆明站M---可持续化线段树
- 题目链接:第45届国际大学生编程设计竞赛昆明站M
- 题目描述及思路:
text
Stone Games(第 45 届 ICPC 昆明区域赛 M / 牛客 12548 M)
【题目描述】
有 n 堆石子,第 i 堆有 s_i 个石子。
Rika 和 Satoko 玩一个游戏。每轮游戏,Rika 从所有石子堆中选出一些堆,
设选出的集合为 S。Satoko 写下一个非负整数 x。
如果 Rika 能从 S 中选出一些堆,使得这些堆的石子数之和恰好等于 x,
则 Rika 获胜;否则 Satoko 获胜。
(每堆石子每轮最多取一次,允许不取任何堆,此时 x = 0。)
一共有 Q 轮游戏。第 i 轮,Rika 会选择下标在区间 [l_i, r_i] 内的所有石子堆作为 S。
对于每轮,Satoko 想知道她能写下的最小整数 x 使得自己获胜。
输入中的 l_i, r_i 是加密的,需要根据上一轮的答案 ans_{i-1} 解密:
l_i = min{ (l_i' + ans_{i-1}) mod n + 1, (r_i' + ans_{i-1}) mod n + 1 }
r_i = max{ (l_i' + ans_{i-1}) mod n + 1, (r_i' + ans_{i-1}) mod n + 1 }
其中 ans_0 = 0。
【输入】
第一行:n, Q(1 ≤ n ≤ 10^6,1 ≤ Q ≤ 10^5)
第二行:n 个整数 s_1, ..., s_n(1 ≤ s_i ≤ 10^9)
接下来 Q 行:每行两个整数 l_i', r_i'(1 ≤ l_i', r_i' ≤ n)
【输出】
Q 行,每行一个整数 ans_i,表示第 i 轮 Satoko 能写下的最小获胜整数。
【样例1】
输入:
5 5
1 4 2 1 6
1 3
2 1
2 4
1 4
3 4
输出:
8
15
4
9
4
【样例解释】
实际查询区间依次为 [2,4], [1,5], [3,5], [1,4], [3,4]。
以第一轮为例,区间 [2,4] 内的石子堆为 {4, 2, 1},能表示的所有子集和为 [0, 7],
所以最小的不可表示数是 8。
【思路解说】
【一、核心结论:可表示集是连续区间】
设当前选出的石子堆集合为 S。可以证明:S 的所有子集和构成的集合,
一定是一个连续的区间 [0, R]。
初始时 R = 0(空集和为 0)。
如果 S 中存在一个数 v,满足 v ≤ R + 1,那么加入 v 后,
可表示的范围会扩展到 [0, R + v](因为 v 可以和 [0, R] 中的任意数组合)。
所以我们可以不断吸收满足 v ≤ R + 1 的数,扩展 R。
最终,当不存在满足 v ≤ R + 1 的数时,R + 1 就是最小的不可表示数。
【二、迭代过程】
1) 初始 R = 0。
2) 设 T = R + 1。
3) 查询区间 [l, r] 内所有值 ≤ T 的元素之和 S。
4) 如果 S == R,说明没有新的数可以吸收,答案就是 R + 1。
5) 否则,令 R = S,回到步骤 2。
由于每次 R 至少翻倍,迭代次数不超过 O(log V),其中 V 是值域上限。
【三、在线查询区间内值 ≤ T 的元素之和】
需要支持:给定区间 [l, r] 和阈值 T,求区间内所有 ≤ T 的元素之和。
用**可持久化权值线段树(主席树)**:
- 对序列的每个前缀建一棵权值线段树。
- 查询时用 root[r] - root[l-1] 得到区间内的信息。
- 查询值域 [1, T] 的和,就是主席树上的前缀和查询。
【四、离散化】
由于 s_i 可达 10^9,但 n 只有 10^6,所以先对 s_i 离散化。
主席树的值域是离散化后的下标 1..m。
查询时用 upper_bound 找到第一个 > T 的离散化位置 K,
然后查询主席树上 rank ≤ K 的元素之和。
【五、算法流程】
1) 读入 n, Q 和序列 s。
2) 离散化 s,得到 vals 数组和每个 s_i 的 rank。
3) 建主席树:对每个前缀 i,插入 s_i 的值。
4) 对于每轮查询:
- 解密 l, r。
- 初始化 R = 0。
- 循环:
T = R + 1
K = upper_bound(vals, T) 的位置
S = 主席树查询区间 [l, r] 内 rank ≤ K 的和
如果 S == R,跳出循环
R = S
- 答案 ans = R + 1。
- 输出 ans。
【六、复杂度】
- 建主席树:O(n log n)
- 每轮查询:O(log² n)(外层迭代 O(log V) 次,内层主席树 O(log n))
- 总复杂度:O((n + Q) log² n),可以过。
【七、注意事项】
- 主席树节点数要开够:n × log2(n) ≈ n × 20。
- 解密时注意是 (x + ans) % n + 1,不是 (x + ans) % (n + 1)。
- 每次查询后更新 ans,用于下一轮解密。
- 所有和要用 long long,因为可能超过 int。
- 迭代终止条件是 S == R,此时 R + 1 就是答案。
- 主席树查询时,如果 K <= 0 直接返回 0。
- 如果 K >= r,直接返回整个区间的和。
- 基于以上思路代码如下:
cpp
// Stone Games - The 45th ICPC Asia Regional (nowcoder contest 12548, problem M)
// 求区间 [l,r] 子集和中从 0 起第一个不可表示的数(询问被上次答案加密,强制在线)。
// 结论:可表示集恒为连续区间 [0,R];v 可吸收 ⟺ v <= R+1;吸收所有 <=R+1 的数后
// R = 区间内所有 <= R+1 的元素之和。迭代到不动点,答案 R+1。
// 在线求「区间内值 <=T 的元素之和」-> 主席树(可持久化权值线段树)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, Q;
cin >> n >> Q;
vector<int> s(n + 1); // 原序列,1-indexed
vector<int> vals(n); // 离散化用的值数组
for (int i = 1; i <= n; i++) {
cin >> s[i];
vals[i - 1] = s[i];
}
// 离散化:排序 + 去重
sort(vals.begin(), vals.end());
int m = (int)(unique(vals.begin(), vals.end()) - vals.begin());
vals.resize(m);
// 可持久化权值线段树(主席树)
// ls, rs: 左右儿子编号;sm: 区间和
vector<int> ls, rs;
vector<ll> sm;
ls.assign((size_t)n * 22 + 5, 0); // 每次插入新建 O(log n) 个节点,n * 22 足够
rs.assign((size_t)n * 22 + 5, 0);
sm.assign((size_t)n * 22 + 5, 0);
int tot = 0; // 节点计数器,0 号节点为空
vector<int> root(n + 1, 0); // root[i] 表示前 i 个数的主席树根
// 插入函数:在 prev 版本的基础上,把值 pos 的位置加上 val
function<int(int,int,int,int,ll)> insert = [&](int prev, int l, int r, int pos, ll val) -> int {
int cur = ++tot;
ls[cur] = ls[prev];
rs[cur] = rs[prev];
sm[cur] = sm[prev] + val;
if (l == r) return cur;
int mid = (l + r) >> 1;
if (pos <= mid) ls[cur] = insert(ls[prev], l, mid, pos, val);
else rs[cur] = insert(rs[prev], mid + 1, r, pos, val);
return cur;
};
// 建主席树:对每个前缀插入 s[i]
for (int i = 1; i <= n; i++) {
// 找到 s[i] 在离散化数组中的排名(1..m)
int rk = (int)(lower_bound(vals.begin(), vals.end(), s[i]) - vals.begin()) + 1;
root[i] = insert(root[i - 1], 1, m, rk, s[i]);
}
// 查询函数:在版本 u 和 v 的差(即区间 [l, r])中,
// 查询 rank <= K 的所有元素之和
function<ll(int,int,int,int,int)> query = [&](int u, int v, int l, int r, int K) -> ll {
if (K <= 0) return 0;
if (K >= r) return sm[u] - sm[v];
int mid = (l + r) >> 1;
if (K <= mid) return query(ls[u], ls[v], l, mid, K);
return (sm[ls[u]] - sm[ls[v]]) + query(rs[u], rs[v], mid + 1, r, K);
};
ll ans = 0; // 上一轮的答案,初始为 0
while (Q--) {
ll lp, rp;
cin >> lp >> rp;
int l = (int)min((lp + ans) % n + 1, (rp + ans) % n + 1);
int rr = (int)max((lp + ans) % n + 1, (rp + ans) % n + 1);
// 迭代求最大的可表示 R
ll R = 0;
while (true) {
ll T = R + 1; // 当前可吸收的阈值
// 找到第一个 > T 的离散化位置,即 rank <= K 的值都 <= T
int K = (int)(upper_bound(vals.begin(), vals.end(), T) - vals.begin());
// 查询区间 [l, r] 内 rank <= K 的元素之和
ll S = (K <= 0) ? 0 : query(root[rr], root[l - 1], 1, m, K);
if (S == R) break; // 没有新的数可以吸收,达到不动点
R = S; // 扩展可表示范围
}
ans = R + 1; // 最小不可表示数
cout << ans << '\n';
}
return 0;
}
2022年中国大学生编程设计竞赛桂林站M---逆序对问题
- 题目链接:2022年中国大学生编程设计竞赛桂林站M
- 题目描述及思路:
text
Youth Finale(2022 CCPC 桂林站 M / CF Gym 104008 M)
【题目描述】
冒泡排序是一种经典的排序算法。给定一个长度为 n 的排列 p,定义"交换次数"为
冒泡排序过程中执行 swap 的总次数。
现在有一个初始排列 p,以及 m 次操作,操作有两种:
- 'R':反转排列,即 p' = {p_n, p_{n-1}, ..., p_2, p_1}
- 'S':左移一位,即 p' = {p_2, p_3, ..., p_n, p_1}
每次操作后,你需要输出:如果对这个新排列执行冒泡排序,会执行多少次 swap。
输出交换次数对 10 取模的结果。
【输入】
第一行:n, m(1 ≤ n ≤ 3×10^5,1 ≤ m ≤ 6×10^5)
第二行:n 个整数,表示初始排列 p
第三行:一个长度为 m 的字符串,只包含 'R' 和 'S',表示 m 次操作
保证 p 是 1..n 的一个排列。
【输出】
第一行:初始排列的冒泡排序交换次数
第二行:一个长度为 m 的字符串,第 i 位表示第 i 次操作后的交换次数对 10 取模
【样例1】
输入:
5 10
5 4 3 2 1
SSSSRSSSSR
输出:
10
6446466400
【思路解说】
【一、核心观察:冒泡排序交换次数 = 逆序对数量】
冒泡排序中,每次 swap 恰好消除一个逆序对(相邻逆序对)。
最终逆序对全部消除,排序完成。
所以,冒泡排序的交换次数 = 排列的**逆序对总数**。
因此问题转化为:维护一个排列的逆序对数量,支持两种操作:
- 反转排列
- 左移一位
【二、反转排列对逆序对的影响】
设原排列的逆序对数为 inv。
反转后,原来的顺序对变成逆序对,逆序对变成顺序对。
总对数 = n(n-1)/2。
所以反转后的逆序对数:
inv' = n(n-1)/2 - inv
**注意:** 这个公式是"全局反转"的公式。
在代码中,我们用 `front` 指针和 `dir` 方向来表示当前排列,
反转操作只需要改变 `dir` 和 `front` 指针,同时更新 `inv`。
【三、左移一位对逆序对的影响】
左移一位:把第一个元素 p[front] 移到最后。
设当前第一个元素的值为 x。
- 原来 x 与其他元素的逆序对:
- x 与后面比它小的元素构成逆序对,数量 = x - 1。
- 左移后,x 移到最后,它与前面所有元素的关系变成:
- x 与前面比它大的元素构成逆序对,数量 = n - x。
- 所以逆序对的变化:
inv' = inv - (x - 1) + (n - x) = inv + n - 2x + 1。
**公式:** inv' = inv + n - 2*x + 1,其中 x 是当前第一个元素的值。
【四、用环形数组 + 方向标记维护当前排列】
用 `front` 表示当前排列第一个元素在数组 a 中的下标。
用 `dir` 表示方向:dir = 1 表示正常方向,dir = -1 表示反转方向。
**初始状态:**
front = 0, dir = 1
**左移操作 'S':**
- 当前第一个元素是 a[front]。
- 更新 inv:inv += n + 1 - 2 * a[front]。
- 移动 front:front = (front + dir) % n(注意处理负数)。
**反转操作 'R':**
- 更新 inv:inv = n*(n-1)/2 - inv。
- 反转后,原来的最后一个元素变成第一个元素。
最后一个元素的下标 = (front + (n-1)*dir) % n。
- 所以 front 需要跳到这个位置。
- 同时 dir = -dir。
**注意:** 在代码中,front 的更新用 `((front + dir) % n + n) % n` 处理负数。
反转时 front 的更新用 `((front + (n-1)*dir) % n + n) % n`。
【五、初始逆序对的计算:树状数组】
用树状数组从右往左统计逆序对:
- 对于每个 a[i],查询树状数组中比 a[i] 小的元素个数(即 query(a[i]-1))。
- 累加到 inv。
- 然后把 a[i] 加入树状数组。
**为什么从右往左?**
因为我们要统计"右边比当前元素小的个数",从右往左遍历可以保证
树状数组中存储的都是当前元素右边的元素。
【六、算法流程】
1) 读入 n, m 和排列 a,以及操作字符串 ops。
2) 用树状数组计算初始逆序对 inv。
3) 输出 inv。
4) 初始化 front = 0, dir = 1。
5) 对于每个操作:
- 如果是 'S':
x = a[front]
inv += n + 1 - 2*x
front = ((front + dir) % n + n) % n
- 如果是 'R':
inv = n*(n-1)/2 - inv
front = ((front + (n-1)*dir) % n + n) % n
dir = -dir
- 输出 inv % 10(注意负数处理)。
6) 输出所有结果拼成的字符串。
【七、复杂度】
- 初始逆序对:O(n log n)
- 每次操作:O(1)
- 总复杂度:O(n log n + m),可以过。
【八、注意事项】
- 逆序对数量可能很大,要用 long long。
- 反转公式:inv' = n*(n-1)/2 - inv。
- 左移公式:inv' = inv + n - 2*x + 1,其中 x 是当前第一个元素。
- front 指针的更新要处理负数取模。
- 输出对 10 取模,注意负数情况:((inv % 10) + 10) % 10。
- 多组测试要清空数组。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class YouthFinale {
public:
void run() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
a.resize(n);
for (int i = 0; i < n; i++) cin >> a[i];
cin >> ops;
// 计算初始排列的逆序对数量(= 冒泡排序交换次数)
long long inv = countInversions();
cout << inv << "\n";
int front = 0; // 当前排列第一个元素在数组 a 中的下标
int dir = 1; // 方向:1 表示正常方向,-1 表示反转方向
string out;
out.reserve(m);
for (char op : ops) {
if (op == 'S') {
// ===== 左移一位 =====
// 当前第一个元素是 a[front]
// 左移后,它移到最后,逆序对变化:inv += n + 1 - 2*x
int x = a[front];
inv += (long long)n + 1 - 2LL * x;
// front 沿着方向移动一位
front = ((front + dir) % n + n) % n;
} else {
// ===== 反转排列 =====
// 总对数 = n*(n-1)/2,反转后逆序对 = 总对数 - 原逆序对
inv = (long long)n * (n - 1) / 2 - inv;
// 反转后,原来的最后一个元素变成第一个元素
// 最后一个元素的下标 = front + (n-1)*dir(模 n)
front = ((front + (long long)(n - 1) * dir) % n + n) % n;
// 方向取反
dir = -dir;
}
// 输出当前逆序对数量对 10 取模(注意处理负数)
long long d = ((inv % 10) + 10) % 10;
out.push_back(char('0' + d));
}
cout << out << "\n";
}
private:
int n = 0, m = 0;
string ops;
vector<int> a;
vector<long long> bit; // 树状数组
// 树状数组单点更新:位置 i 增加 v
void update(int i, long long v) {
for (; i <= n; i += i & (-i)) bit[i] += v;
}
// 树状数组前缀查询:1..i 的和
long long query(int i) {
long long s = 0;
for (; i > 0; i -= i & (-i)) s += bit[i];
return s;
}
// 用树状数组计算初始排列的逆序对数量
// 从右往左遍历,统计右侧比 a[i] 小的元素个数
long long countInversions() {
bit.assign(n + 1, 0);
long long inv = 0;
for (int i = n - 1; i >= 0; i--) {
inv += query(a[i] - 1); // 右侧比 a[i] 小的个数
update(a[i], 1); // 把 a[i] 加入树状数组
}
return inv;
}
};
int main() {
YouthFinale solver;
solver.run();
return 0;
}
第46届ICPC沈阳站H---最大生成树
-
题目链接:第46届ICPC沈阳站H
-
核心思路:
text【题目描述】 在数学图论中,简单无向带权图 G 的线图 L(G) 是另一个简单无向带权图, 它表示 G 中每两条边之间的邻接关系。 具体来说,对于无自环、无重边的无向带权图 G,它的线图 L(G) 定义如下: - L(G) 的每个顶点对应 G 的一条边; - L(G) 中两个顶点相邻,当且仅当它们对应的两条边在 G 中共享一个公共端点; - 这两个顶点之间的边权 = 这两条边在 G 中的权值之和。 最大权匹配定义为:选出一组边,任意两条边不共享公共顶点,使得边权和最大。 给定一个简单无向连通带权图 G,求 L(G) 的最大权匹配的边权和。 【输入格式】 第一行两个整数 n 和 m,表示 G 的点数和边数。 接下来 m 行,每行三个整数 u, v, w,表示 G 中一条边连接 u 和 v,权值为 w。 保证 G 是连通的,无自环,无重边。 【输出格式】 输出一行一个整数,表示 L(G) 的最大权匹配的边权和。 【样例1】 【输入】: 5 6 1 2 1 1 3 2 1 4 3 4 3 4 4 5 5 2 5 6 【输出】: 21 【样例2】 【输入】: 6 5 1 2 4 2 3 1 3 4 3 4 5 2 5 6 5 【输出】: 12 【样例】3 【输入】: 5 5 1 2 1 2 3 2 3 4 3 4 5 4 5 1 5 【输出】: 14 【思路解说】 一、问题转化 线图 L(G) 的匹配,等价于在 G 中选出一组边对: - 每对边共享一个端点; - 不同边对之间不共享端点。 每对边的贡献 = 两条边的权值和。 目标:最大化所有配对边的权值和。 二、核心结论 设 S = sum(w_i) 为 G 中所有边的权值和。 - 如果 G 中所有点的度数都是偶数:答案是 S - min(w_i)。 - 否则(存在度数为奇数的点):答案是 S。 直观理解: - 所有点度数为偶数时,G 是欧拉图,无法把所有边完美配对, 必然剩下一条边,舍弃权值最小的那条最优。 - 存在奇度点时,可以做到完美配对,覆盖所有边。 三、并查集 + 贪心做法 不用显式判断度数奇偶性,直接用并查集模拟配对过程。 核心思想: - 把边按权值从大到小排序,依次处理。 - 用并查集维护连通块,每个连通块内最多只有一条「挂起等待配对」的边。 - 每次处理一条边时,判断它能否和块内挂起的边配对: - 如果两端在同一连通块,说明当前边和块内挂起的边共享端点,直接配对。 - 如果两端在不同连通块,则根据两边是否有挂起的边来决定: - 两边都无挂起边:当前边先挂到其中一块。 - 至少一边有挂起边:优先和权值大的那条配对,小的继续挂起。 - 最后合并两个连通块。 为什么贪心正确: - 边权从大到小处理,保证每次配对都是当前能配的最大组合。 - 并查集保证「挂起的边」和「当前边」在同一连通块内,即它们共享端点。 - 在线图这种特殊结构上,该贪心恰好得到最优解。 四、复杂度 - 排序:O(m log m) - 并查集:O(m α(n)) - 总复杂度:O(m log m),m <= 2e5,可过。 样例验证 -------- 样例2: 6 5 1 2 4 2 3 1 3 4 3 4 5 2 5 6 5 按权值降序:5(5,6), 4(1,2), 3(3,4), 2(4,5), 1(2,3) - 5-6 权 5:不同块,无挂起,先挂起 - 1-2 权 4:不同块,无挂起,先挂起 - 3-4 权 3:不同块,无挂起,先挂起 - 4-5 权 2:4 所在块挂起 3,5 所在块挂起 5, 配大的:贡献 2+5=7,小的 3 继续挂起 - 2-3 权 1:2 所在块挂起 4,3 所在块挂起 3, 配大的:贡献 1+4=5,小的 3 继续挂起 总贡献 = 7 + 5 = 12 ✓ 总结 ---- - 线图匹配 等价于 原图边配对问题。 - 结论:全偶度数 则 总和 - 最小边权;否则 则 总和。 - 并查集 + 贪心可以直接模拟配对,无需显式判断度数。 - 复杂度 O(m log m),适合 m <= 2e5。-
根据以上思路,代码如下:
cpp#include<bits/stdc++.h> using namespace std; typedef long long ll; const int N = 100010, M = 200010; int INF = 0x3f3f3f3f, mod = 1e9 + 7; int n, m; int p[N], f[N]; // p: 并查集父节点; f: 当前连通块中「等待配对」的边的权值 // 边结构体 struct edge { int a, b, x; // 按边权从大到小排序 bool operator<(const edge& ee) const { return x > ee.x; } } ed[M]; // 并查集查找(路径压缩) int find(int x) { if (p[x] != x) p[x] = find(p[x]); return p[x]; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); cin >> n >> m; // 并查集初始化 for (int i = 1; i <= n; i++) p[i] = i; // 读入 m 条边 for (int i = 1; i <= m; i++) { int a, b, x; cin >> a >> b >> x; ed[i] = {a, b, x}; } // 按边权从大到小排序,贪心优先处理大边 sort(ed + 1, ed + 1 + m); ll ans = 0; // 已配对成功的贡献总和 for (int i = 1; i <= m; i++) { int a = ed[i].a, b = ed[i].b, w = ed[i].x; a = find(a), b = find(b); if (a == b) { // 当前边两端在同一连通块内 // 说明当前边和块内「挂起」的那条边共享端点,可以配对 if (!f[a]) { // 块内没有等待配对的边,当前边先挂起等待 f[a] = w; } else { // 有等待的边,直接配对 // 贡献 = 当前边权 + 挂起边权 ans += w + f[a]; f[a] = 0; // 配对后清空挂起状态 } } else { // 当前边连接两个不同连通块 if (!f[a] && !f[b]) { // 两边都没有等待配对的边 // 当前边可以放入任一块等待 f[b] = f[a] = w; } else { // 至少有一边有等待配对的边 // 优先和大的那条配对(贪心) // 贡献 = 当前边权 + max(两块的挂起边权) ans += w + max(f[a], f[b]); // 小的那个继续等待配对 f[a] = f[b] = min(f[a], f[b]); } // 合并两个连通块 p[a] = b; } } cout << ans; return 0; }
-
第46届ICPC沈阳站B---带权并查集
- 题目链接:第46届ICPC沈阳站B
- 题目描述及其思路:
text
Bitwise Exclusive-OR Sequence(ICPC 沈阳 2021 B / 牛客 56483 B)
【题目描述】
给定一个长度为 n 的非负整数序列 a1, a2, ..., an,以及 m 个约束条件。
每个约束条件的形式为:
a_u ⊕ a_v = w
其中 ⊕ 表示按位异或运算,w 是一个非负整数(0 ≤ w < 2^30)。
你需要找到一个满足所有约束条件的序列,使得所有元素之和尽可能小。
如果不存在满足所有约束的序列,输出 -1。
【输入】
第一行:n 和 m(1 ≤ n ≤ 10^5,0 ≤ m ≤ 2 × 10^5)
接下来 m 行:每行三个整数 u, v, w(1 ≤ u, v ≤ n,0 ≤ w < 2^30)
表示约束 a_u ⊕ a_v = w。
【输出】
输出一行一个整数,表示满足所有约束的序列中所有元素之和的最小值。
如果无解,输出 -1。
【样例1】
输入:
3 2
1 2 1
2 3 1
输出:
1
【说明】
样例中,序列 [a1, a2, a3] = [0, 1, 0] 满足所有约束,且总和最小为 1。
【思路解说】
【一、为什么这道题需要带权并查集?】
先看题目给的条件:a_u ⊕ a_v = w。
这个条件不是简单的"两个数相等"或"两个数属于同一类",
而是一种**带数值的关系**。普通的并查集只能维护"两个元素是否属于同一集合",
无法直接表达"a_u 和 a_v 的异或值等于 w"这种信息。
但是我们注意到一个关键性质:**异或关系是可以传递的**。
假设我们知道:
a_1 ⊕ a_2 = w1
a_2 ⊕ a_3 = w2
那么可以推出:
a_1 ⊕ a_3 = (a_1 ⊕ a_2) ⊕ (a_2 ⊕ a_3) = w1 ⊕ w2
也就是说,只要确定了某个"基准点"的值,其他所有和它有异或关系的点
都可以通过异或运算推导出来。
这正好符合带权并查集的思想:
- 普通并查集维护"是否在同一集合";
- 带权并查集额外维护"每个点到根节点的权值"。
在这道题中,我们让:
- 每个节点 x 的权值 dis[x] = a_x ⊕ a_root(x)
- 即 dis[x] 表示 a_x 与它所在集合根节点的异或值
这样,任意两个在同一集合中的节点 u 和 v,都可以通过根节点推出它们的关系:
a_u ⊕ a_v = dis[u] ⊕ dis[v]
这是因为:
a_u ⊕ a_v = (a_u ⊕ a_root) ⊕ (a_root ⊕ a_v) = dis[u] ⊕ dis[v]
所以,带权并查集可以完美地维护异或关系。
【二、带权并查集的核心操作】
1. 初始化:
- fa[x] = x
- dis[x] = 0(自己到自己的异或值为 0)
2. 查找(find):
- 递归找到根节点
- 路径压缩时,更新 dis[x] 为 x 到新根节点的异或值
- 更新公式:dis[x] ^= dis[fa[x]]
3. 合并(unite):
- 给定约束 a_u ⊕ a_v = w
- 先找到 u 和 v 的根 fu 和 fv
- 如果 fu == fv:
检查 dis[u] ⊕ dis[v] 是否等于 w,不等则无解
- 如果 fu != fv:
合并两个集合,把 fv 挂到 fu 下
需要满足:dis[u] ⊕ dis[v] ⊕ (a_fu ⊕ a_fv) = w
所以:a_fu ⊕ a_fv = dis[u] ⊕ dis[v] ⊕ w
即:dis[fv] = dis[u] ⊕ dis[v] ⊕ w
【三、为什么可以按位独立处理?】
异或运算的一个重要性质是:**每一位是独立的**。
对于约束 a_u ⊕ a_v = w,展开成二进制:
- 第 b 位上,a_u 和 a_v 的异或值等于 w 的第 b 位
- 如果 w 的第 b 位是 0,则 a_u 和 a_v 在第 b 位上必须相同
- 如果 w 的第 b 位是 1,则 a_u 和 a_v 在第 b 位上必须不同
由于每一位互不影响,我们可以把问题拆成 30 个独立的子问题
(因为 w < 2^30,所以只需要考虑 0 到 29 位)。
但注意:带权并查集本身维护的是完整的异或值,不需要真的拆成 30 次。
我们只需要在最后统计答案时,按位分别统计即可。
【四、如何最小化总和?】
合并完成后,图被分成若干个连通分量。
在每个连通分量中,只要确定了根节点的值,其余所有节点的值就都确定了。
我们的目标是让整个序列的总和最小。
由于每一位独立,我们可以对每个连通分量、每一位单独决策:
- 对于某个连通分量、某一位 b,设该分量中有 cnt1 个节点的 dis 值在第 b 位为 1,
cnt0 个节点的 dis 值在第 b 位为 0。
- 如果根节点在第 b 位取 0,则该位为 1 的节点数是 cnt1;
- 如果根节点在第 b 位取 1,则该位为 1 的节点数是 cnt0。
- 为了让总和最小,我们取 min(cnt1, cnt0) 个节点在该位为 1。
所以该位对该连通分量的贡献是:
min(cnt1, cnt0) × 2^b
【五、算法流程】
1) 读入 n, m,初始化带权并查集:
fa[i] = i, dis[i] = 0
2) 依次处理每个约束 (u, v, w):
- 如果 u 和 v 已在同一集合,检查 dis[u] ⊕ dis[v] == w,
不满足则输出 -1 并结束。
- 否则合并两个集合,更新 dis[fv] = dis[u] ⊕ dis[v] ⊕ w。
3) 按连通分量分组,把每个节点归到其根节点下:
comp[find(i)].push_back(i)
4) 对每个连通分量,对每一位 b(0 ≤ b < 30):
- 统计该分量中 dis[x] 第 b 位为 1 的节点数 cnt1;
- cnt0 = 分量大小 - cnt1;
- 答案累加 min(cnt1, cnt0) × 2^b。
5) 输出答案。
【六、复杂度分析】
- 并查集操作:O(m α(n))
- 按位统计:O(30n)
- 总复杂度:O(m α(n) + 30n),可以过。
【七、注意事项】
- 异或运算满足交换律和结合律,这是带权并查集能成立的基础。
- 路径压缩时,dis[x] 要累加到根节点的异或值:
dis[x] ^= dis[fa[x]]
- 合并时 dis[fv] = dis[u] ^ dis[v] ^ w 的推导要理解清楚。
- 答案可能很大,要用 long long。
- 多组测试要清空 comp 数组。
- w < 2^30,所以只需要考虑 30 位。
- 如果无解,直接输出 -1。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
int n, m;
int fa[MAXN];
ll dis[MAXN]; // dis[x] = a[x] ^ a[fa[x]](路径压缩后是到根的异或值)
vector<int> comp[MAXN]; // 每个连通分量的节点
bool vis[MAXN];
// 带权并查集:查找 + 路径压缩
int find(int x) {
if (fa[x] == x) return x;
int root = find(fa[x]);
dis[x] ^= dis[fa[x]]; // 路径压缩时更新异或值
return fa[x] = root;
}
// 合并:a[u] ^ a[v] = w
bool unite(int u, int v, ll w) {
int fu = find(u), fv = find(v);
if (fu == fv) {
// 已经在同一集合,检查是否满足约束
return (dis[u] ^ dis[v]) == w;
}
// 合并:把 fv 挂到 fu 下
// 需要满足 dis[u] ^ dis[v] = w
// 即 (a[u] ^ a[fu]) ^ (a[v] ^ a[fv]) ^ (a[fu] ^ a[fv]) = w
// 所以 a[fu] ^ a[fv] = dis[u] ^ dis[v] ^ w
fa[fv] = fu;
dis[fv] = dis[u] ^ dis[v] ^ w;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
// 初始化并查集
for (int i = 1; i <= n; i++) {
fa[i] = i;
dis[i] = 0;
}
// 处理所有约束
bool ok = true;
for (int i = 0; i < m; i++) {
int u, v;
ll w;
cin >> u >> v >> w;
if (!unite(u, v, w)) {
ok = false;
}
}
if (!ok) {
cout << -1 << '\n';
return 0;
}
// 按连通分量分组
for (int i = 1; i <= n; i++) {
int root = find(i);
comp[root].push_back(i);
}
ll ans = 0;
// 对每个连通分量,逐位统计
for (int root = 1; root <= n; root++) {
if (comp[root].empty()) continue;
// 对每个二进制位,统计该分量中该位为 1 的个数
for (int b = 0; b < 30; b++) {
int cnt1 = 0;
for (int x : comp[root]) {
// a[x] 的第 b 位 = dis[x] 的第 b 位 ^ a[root] 的第 b 位
// 我们统计 dis[x] 的第 b 位为 1 的个数
if ((dis[x] >> b) & 1) cnt1++;
}
int cnt0 = comp[root].size() - cnt1;
// 我们可以选择 a[root] 的第 b 位为 0 或 1
// 如果 a[root] 第 b 位为 0,则该位为 1 的个数是 cnt1
// 如果 a[root] 第 b 位为 1,则该位为 1 的个数是 cnt0
// 取较小值
ans += (ll)min(cnt1, cnt0) * (1LL << b);
}
}
cout << ans << '\n';
return 0;
}
2022南京站B---DP+单调队列优化
-
题目链接:2022南京站B
-
【题目描述和思路】
text【一、题目描述】 紫金山的索道要建支撑塔。起点在 0,终点在 n+1。 位置 1..n 可以建支撑塔,建在 i 的花费是 a[i]。 给定一个 01 串 s: · s[i] = '1' 表示位置 i 必须建塔 · s[i] = '0' 表示位置 i 可建可不建 要求:任意两个相邻的塔(或站点)之间的距离 ≤ k。 有 q 次临时修改 (p, v):把 a[p] 临时改成 v, 每次修改后求最小总花费。每次修改独立(互不影响,算完还原)。 1 ≤ n ≤ 5e5,1 ≤ k ≤ min(n+1, 3e3),q ≤ 3e3, Σn ≤ 2e6,Σk ≤ 1e4,Σq ≤ 1e4。 【二、核心模型:滑动窗口 DP】 设 f[i] = 在位置 i 建塔,且满足 0..i 之间所有约束的最小花费。 转移:i 可以从前面 k 个位置中的任意一个 j 转移过来 (因为相邻距离 ≤ k,所以 j 至少是 i-k): f[i] = a[i] + min{ f[j] : max(0, i-k) ≤ j < i } f[0] = 0(起点 0),f[n+1] = 0(终点 n+1)。 答案 = f[n+1]。 ★ 本质是带滑动窗口最小值的最短路 / DP。 ★ 朴素转移 O(k),总 O(nk),用单调队列优化到 O(n)。 【三、强制建塔的处理】 对 s[i] = '1' 的位置,必须建塔。 处理方式:在单调队列里,遇到强制点 i 时,直接清空队列, 只保留 i 自己。这样: · i 之前的候选全部作废(因为 i 必须建,不能从更早的位置跨过 i) · i 之后只能从 i 开始转移(窗口重置) ★ 这相当于把强制点作为"段的分界点", 每段内部独立做滑动窗口 DP。 【四、处理 q 次临时修改】 每次修改只改一个 a[p],q ≤ 3e3, 如果每次重跑 O(n) 的 DP,总复杂度 O(qn) 会超时。 ★ 关键:预处理前缀 DP 和后缀 DP, 修改 p 时,只重算 [p-k, p+k] 这一小段,再和前后缀拼接。 具体: · dp[i] = 从 0 到 i(含 i)的最优 DP 值(前缀) · last[i] = 从 i(含 i)到 n+1 的最优 DP 值(后缀) · 修改 p 后,只需要重算 [p-k, p+k] 范围内的 dp 值, 再枚举拼接点 i,用 dp[i] + last[i] - a[i] 更新答案。 ★ 因为相邻距离 ≤ k,修改 p 只会影响 [p-k, p+k] 这段。 q 次修改,每次 O(k),总复杂度 O(qk) ≤ 3e3 × 3e3 = 9e6,可过。 【五、前后缀 DP 的预处理】 ① 前缀 DP: dp[0] = 0,a[0] = 0,队列初始含 0。 for i = 1..n+1: dp[i] = dp[队首] + a[i] 若 s[i]=='1':清空队列,只留 i 否则:弹出窗口外的队首;维护队尾单调递增;压入 i ② 后缀 DP: last[n+1] = 0,a[n+1] = 0,队列初始含 n+1。 for i = n..1: last[i] = last[队首] + a[i] 若 s[i]=='1':清空队列,只留 i 否则:弹出窗口外的队首;维护队尾单调递增;压入 i ★ 前缀从 0 推到 n+1,后缀从 n+1 推回 1。 【六、单次修改的处理】 对修改 (p, v): ① 从 [max(0, p-k), p-1] 开始,重建一个队列 (沿用 dp 数组已有的值,只需把这段的候选加入队列) ② 对 i = p..min(n+1, p+k-1): 若 i == p:dp[i] = dp[队首] + v(用新的 a[p] = v) 否则: dp[i] = dp[队首] + a[i] 按 s[i] 是否为 '1' 处理队列(清空 / 滑动 / 维护单调) 用 dp[i] + last[i] - a[i] 更新答案 ③ 用临时数组 tmp 保存 dp 被修改的部分,最后还原 ★ 注意:a[p] 被改成 v 只影响这一步,算完要还原 a[p](或用临时变量)。 【七、算法流程】 1. 读入 n, k, a[], s[], q。 2. 预处理前缀 dp[] 和后缀 last[]。 3. 对每次修改 (p, v): a. 重建 [p-k, p-1] 段的队列 b. 重算 [p, p+k-1] 段的 dp[],更新答案 c. 还原 dp[] 被修改的部分 4. 输出每次修改的答案。 5. 结束。 【八、复杂度】 预处理:O(n) 每次修改:O(k) 总复杂度:O(n + qk),可过 Σn ≤ 2e6 + Σqk ≤ 1e4 × 3e3。 【九、样例理解】 样例 1:n=10, k=3 a = {5,10,7,100,4,3,12,5,100,100} s = "0100001000"(位置 2, 8 强制) 修改 1:a[2] = 3 → 在 2, 4, 6, 9 建塔,总花费 3+100+3+100 = 206 ✓ 修改 2:a[6] = 15 → 在 1, 4, 5, 8, 9 建塔,总花费 5+100+4+5+100 = 214 ✓ 样例 2:k=3,s 全为 0,起点 0 到终点 n+1 距离小, 可以一个塔都不建 → 输出 0 样例 3:位置 3 强制,其他不强制,且 3 到 0、3 到 4 距离都 ≤ k, 只建 3 即可 → 输出 100 【十、总结】 相邻距离 ≤ k → 滑动窗口 DP → f[i] = a[i] + min{ f[j] : i-k ≤ j < i } → s[i]='1' 强制建塔 → 清空队列,作为段分界 → q 次临时修改 → 预处理前后缀,只重算 O(k) 段 → 单调队列优化窗口最小值 → O(n + qk)所以基于以上思路,代码如下:
cpp#include<bits/stdc++.h> #define endl '\n' #define int long long using namespace std; const int N = 5e5 + 10; int a[N]; // a[i] = 在位置 i 建塔的花费 int dp[N]; // dp[i] = 从起点 0 到位置 i(且 i 建塔)的最小花费(前缀 DP) int last[N]; // last[i] = 从位置 i(且 i 建塔)到终点 n+1 的最小花费(后缀 DP) int tmp[N]; // 临时数组,用于保存并还原被修改的 dp 值 int n, k, Q; string s; // 01 串,s[i]='1' 表示位置 i 必须建塔 // 预处理:前缀 DP 和后缀 DP void init() { // ============ 前缀 DP:从 0 推到 n+1 ============ deque<int> q; // 单调队列,存下标,对应的 dp 值单调递增 dp[0] = 0; a[0] = 0; // 虚拟起点 0,花费 0 q.push_back(0); for (int i = 1; i <= n + 1; i++) { // 从队首(窗口内 dp 最小的位置)转移 dp[i] = dp[q.front()] + a[i]; if (s[i] == '1') { // 强制建塔:清空队列,只留 i 自己 // 表示 i 之前的候选全部作废,i 之后的转移只能从 i 开始 q.clear(); q.push_back(i); } else { // 弹出窗口外的队首:窗口是 [i-k+1, i] if (q.size() && i - k + 1 > q.front()) q.pop_front(); // 维护队尾单调递增:dp 值更大的候选不可能成为最优 while (q.size() && dp[q.back()] > dp[i]) q.pop_back(); q.push_back(i); } } // ============ 后缀 DP:从 n+1 推回 1 ============ last[n + 1] = 0; a[n + 1] = 0; // 虚拟终点 n+1,花费 0 deque<int> qq; qq.push_back(n + 1); for (int i = n; i >= 1; i--) { // 从队首(窗口内 last 最小的位置)转移 last[i] = last[qq.front()] + a[i]; if (s[i] == '1') { // 强制建塔:清空队列,只留 i 自己 qq.clear(); qq.push_back(i); } else { // 弹出窗口外的队首:窗口是 [i, i+k-1] if (qq.size() && i + k - 1 < qq.front()) qq.pop_front(); // 维护队尾单调递增 while (qq.size() && last[qq.back()] > last[i]) qq.pop_back(); qq.push_back(i); } } } void solve() { cin >> n >> k; for (int i = 1; i <= n; i++) cin >> a[i]; cin >> s; s = ' ' + s; // 让 s 下标从 1 开始,和 a 对齐 init(); // 预处理前缀和后缀 DP cin >> Q; while (Q--) { int p, v; cin >> p >> v; // 把 a[p] 临时改成 v // ============ 重建 [p-k, p-1] 段的单调队列 ============ // 因为修改 a[p] 只影响 [p-k, p+k-1] 这段的 dp 值 // 所以队列的初始状态从 p-k 开始重建 deque<int> q; for (int i = max(0LL, p - k); i <= p - 1; i++) { if (s[i] == '1') { q.clear(); q.push_back(i); } else { if (q.size() && i - k + 1 > q.front()) q.pop_front(); while (q.size() && dp[q.back()] > dp[i]) q.pop_back(); q.push_back(i); } } // 保存 [p, p+k-1] 段的 dp 值,后面要还原 for (int i = p; i <= min(n + 1, p + k - 1); i++) tmp[i] = dp[i]; int ans = 1e18; // ============ 重算 [p, p+k-1] 段的 dp 值 ============ for (int i = p; i <= min(n + 1, p + k - 1); i++) { // i == p 时用新的值 v,其他位置用原 a[i] if (i == p) dp[i] = dp[q.front()] + v; else dp[i] = dp[q.front()] + a[i]; // 和 init() 里的逻辑一致:强制点清空队列,否则滑动 + 维护单调 if (s[i] == '1') { q.clear(); q.push_back(i); } else { if (q.size() && i - k + 1 > q.front()) q.pop_front(); while (q.size() && dp[q.back()] > dp[i]) q.pop_back(); q.push_back(i); } // 枚举拼接点 i:dp[i] 是前缀(0 到 i),last[i] 是后缀(i 到 n+1) // 两者都算了 a[i] 一次,所以要减掉一个 ans = min(ans, dp[i] + last[i] - a[i]); } cout << ans << endl; // ============ 还原 dp 值 ============ // 修改是临时的,算完要恢复 for (int i = p; i <= min(n + 1, p + k - 1); i++) dp[i] = tmp[i]; } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); int t = 1; cin >> t; while (t--) { solve(); } return 0; }
第9届CCPC深圳站F---基环树
- 题目链接:第9届CCPC深圳站F
- 题目描述及思路:
text
见面礼(CSGCPC 1242 / 基环树)
【题目描述】
给定一个包含 n 个点、n 条边的无向连通图。
你需要删除图中的一条边,使得剩下的图是一棵树,并且存在一个根节点 p,
使得以 p 为根时,树上每个节点的儿子数都不超过 3。
问:有多少种「删边 + 选根」的方案满足条件?
【输入】
第一行一个整数 n(1 ≤ n ≤ 10^5)。
接下来 n 行,每行两个整数 u, v,表示一条无向边。
【输出】
输出一个整数,表示满足条件的方案数。
【样例1】
输入:
6
1 2
1 3
1 4
1 5
1 6
2 3
输出:
10
【样例解释】
删除 (1, 2) 或者 (1, 3),并在 {2, 3, 4, 5, 6} 中选择一个节点作为根,
可以得到所有的合法方案。因此输出为 2 × 5 = 10。
【思路解说】
【一、图的结构:基环树】
n 个点 n 条边且连通 ⇒ 图中恰好有一个环,环上挂着若干棵树。
要变成一棵树,必须且只能删除环上的一条边。
【二、删边后的树满足什么条件】
删掉一条边后,图变成一棵树。选一个根 p,要求所有节点的儿子数 ≤ 3。
对于树中的节点 i:
- 如果 i 是根:儿子数 = 度数(删边后)
- 如果 i 不是根:儿子数 = 度数 - 1(减去连向父亲的那条边)
所以「儿子数 ≤ 3」等价于:
- 根节点:删边后度数 ≤ 3
- 非根节点:删边后度数 ≤ 4
【三、删边的影响】
删掉边 (u, v) 后,只有 u 和 v 的度数减 1,其他点的度数不变。
所以:
- 原图中度数 ≥ 6 的点:删一条边最多减 1,无法降到 ≤ 4,无解。
- 原图中度数 = 5 的点:删边后必须变成 4,所以它必须是 u 或 v。
因此,原图中度数 = 5 的点必须全部在 {u, v} 中。
【四、统计合法根的数量】
设删边后:
- u 的新度数 = du[u] - 1
- v 的新度数 = du[v] - 1
- 其他点度数不变
合法根的条件:删边后度数 ≤ 3。
预处理原图中度数 ≤ 3 的点的个数 cntLE3。
对于删边 (u, v):
- 如果 du[u] == 4,删边后变成 3,u 从「不合法根」变成「合法根」,贡献 +1
- 如果 du[v] == 4,同理 +1
- 其他点度数不变,合法根个数不变
所以:
validRoots = cntLE3 + (du[u] == 4) + (du[v] == 4)
【五、算法流程】
1) 读入 n 和 n 条边,统计每个点的度数 du。
2) 用拓扑排序找环:
- 把所有度数为 1 的点入队。
- 不断删掉度数为 1 的点,最后剩下的就是环上的点。
3) 统计原图中度数 = 5 的点集合 deg5,以及度数 ≤ 3 的点数 cntLE3。
4) 枚举环上的每条边 (u, v):
- 检查 deg5 中的点是否都在 {u, v} 中,否则跳过。
- 计算 validRoots = cntLE3 + (du[u] == 4) + (du[v] == 4)。
- 累加到答案。
5) 输出答案。
【六、复杂度】
- 拓扑排序找环:O(n)
- 枚举环上边:O(n)
- 检查 deg5:由于题目保证有解,|deg5| ≤ 2,所以 O(1)
- 总复杂度:O(n),可以过 10^5。
【七、注意事项】
- n 个点 n 条边的连通图一定是基环树。
- 删边后要变成树,必须删环上的边,不能删树边。
- 度数 = 5 的点必须全部在删边的两个端点中。
- 合法根的统计可以用 O(1) 公式,不需要每次 O(n) 重新统计。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
vector<int> g[MAXN]; // 邻接表存图
int du[MAXN]; // 原图中每个点的度数
int du1[MAXN]; // 拓扑排序用的度数副本(会不断减)
bool onCycle[MAXN]; // 标记点是否在环上
int n;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// 读入 n 个点、n 条边(基环树)
cin >> n;
for (int i = 0; i < n; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
du[u]++; du[v]++; // 原图度数
du1[u]++; du1[v]++; // 拓扑排序用的度数
}
// ===== 拓扑排序找环 =====
// 不断删掉度数为 1 的点(叶子),最后剩下的就是环上的点
queue<int> q;
for (int i = 1; i <= n; i++) {
if (du1[i] == 1) q.push(i);
}
while (!q.empty()) {
int u = q.front(); q.pop();
onCycle[u] = false; // 被删掉的点不在环上
for (int v : g[u]) {
if (--du1[v] == 1) q.push(v);
}
}
// 最后 du1[i] >= 2 的点一定在环上
for (int i = 1; i <= n; i++) {
if (du1[i] >= 2) onCycle[i] = true;
}
// ===== 统计原图中度数 = 5 的点 =====
// 这些点删边后必须变成 4(非根节点儿子数 ≤ 3 ⇒ 度数 ≤ 4)
// 所以它们必须是被删边的两个端点之一
set<int> deg5;
for (int i = 1; i <= n; i++) {
if (du[i] == 5) deg5.insert(i);
}
// ===== 预处理:原图中度数 ≤ 3 的点的个数 =====
// 这些点在删边后度数只会 ≤ 3,天然是合法根
int cntLE3 = 0;
for (int i = 1; i <= n; i++) {
if (du[i] <= 3) cntLE3++;
}
ll ans = 0;
// ===== 枚举环上的每条边 (u, v) =====
for (int u = 1; u <= n; u++) {
if (!onCycle[u]) continue; // 只枚举环上的点
for (int v : g[u]) {
if (v < u || !onCycle[v]) continue; // 避免重复枚举环上的边
// 检查删掉边 (u, v) 是否合法:
// 原图中度数 = 5 的点必须全部在 {u, v} 中
bool ok = true;
for (int x : deg5) {
if (x != u && x != v) { ok = false; break; }
}
if (!ok) continue;
// O(1) 统计删边后合法根的数量:
// 原度数 ≤ 3 的点天然合法
// 如果 u 原度数为 4,删边后变 3,新增一个合法根
// 如果 v 原度数为 4,同理
int validRoots = cntLE3;
if (du[u] == 4) validRoots++;
if (du[v] == 4) validRoots++;
ans += validRoots;
}
}
cout << ans << '\n';
return 0;
}
数论区域赛题
45届区域赛题济南站A---高斯消元法
-
题目链接: 45届区域赛题济南站A
-
核心思路:
text【题目描述】 给定两个 N×N 的 01 矩阵 A、B。 定义两个运算: (X × Y)[i][j] = ( Σ_k X[i][k]·Y[k][j] ) mod 2 (X ⊙ Y)[i][j] = X[i][j] · Y[i][j] 求满足 A×C = B⊙C 的 01 矩阵 C 的个数,答案 mod 998244353。 【输入】 第一行:N 接下来 N 行:每行 N 个整数,第 i 行第 j 个为 A[i][j] 接下来 N 行:每行 N 个整数,第 i 行第 j 个为 B[i][j] 【输出】 一个整数,满足方程的 01 矩阵 C 的个数 mod 998244353 【数据范围】 1 ≤ N ≤ 200,A[i][j], B[i][j] ∈ {0, 1} 【样例1】 输入: 2 0 1 1 1 1 0 0 1 输出: 2 【样例2】 输入: 3 1 0 0 0 1 0 0 0 1 1 1 1 1 1 1 1 1 1 输出: 512 【样例3】 输入: 4 0 1 0 1 0 1 1 0 0 1 1 1 1 0 0 1 1 0 1 1 0 1 1 1 1 0 0 1 1 1 1 0 输出: 8 【思路解说】 【一、按列独立】 设 C 第 j 列为 c_j(N 维 0/1 列向量)。 左式第 j 列 = A · c_j 右式第 j 列 = diag(b_j) · c_j,其中 b_j = B 的第 j 列 所以每列独立满足: A·c_j = diag(b_j)·c_j 移项: M_j · c_j = 0,其中 M_j = A − diag(b_j) ★ 第 j 列独立解一个模 2 齐次方程组,各列解数相乘即总答案。 【二、模 2 下减法即异或】 M_j 的构造:复制 A,然后对每个 i: M_j[i][i] = A[i][i] XOR B[i][j] 【三、齐次方程组解数】 设 M_j 的秩为 r_j,则自由变量数 = N − r_j,每个可取 0/1: 第 j 列解数 = 2^(N − r_j) 【四、总公式】 答案 = ∏_j 2^(N − r_j) = 2^E,其中 E = Σ_j (N − r_j) 最后对 998244353 取模(快速幂)。 【五、算法流程】 1. 读入 N、A、B 2. E = 0 3. 对每列 j = 1..N: a. 复制 A 到临时矩阵 M_j b. M_j[i][i] ^= B[i][j](对所有 i) c. 模 2 高斯消元求秩 r_j d. E += N − r_j 4. 输出 2^E mod 998244353 【六、模 2 高斯消元求秩】 rank = 0 for col = 1..N: ① 在 rank+1..N 行中找第 col 列为 1 的行作主元; 找不到则跳过该列 ② 把主元行换到第 rank+1 行 ③ 对其他行 i:若 M[i][col]=1,则 M[i] ^= M[rank+1] ④ rank++ 返回 rank。 注:模 2 加减均为异或;主元行用 rank+1 而不是 col,避免全零列错位。 【七、复杂度】 朴素:每列 O(N³),共 N 列 → O(N⁴),N=200 约 1.6×10⁹,超时。 bitset 优化:按 64 位并行异或 → O(N⁴/64),约 2.5×10⁷,稳过。 【八、样例验证】 样例1:N=2,A=[[0,1],[1,1]],B=[[1,0],[0,1]] 列1:M₁=[[1,1],[1,1]],rank=1,贡献 1 列2:M₂=[[0,1],[1,0]],rank=2,贡献 0 E=1 → 答案 2^1 = 2 ✓ 样例2:N=3,A=I,B=全1 每列 M_j = 零矩阵,rank=0,贡献 3 E=3×3=9 → 答案 2^9 = 512 ✓ 样例3:N=4,输出 8 ✓ 【九、总结】 按列拆 → 每列模2齐次方程 M_j·c_j=0 → 解数 2^(N−rank) → 全部相乘 → 模2高斯消元求秩 → 快速 -
代码如下:
cpp#include <bits/stdc++.h> using namespace std; static int MAXN = 101; // 预留最大值(本题实际用不到,vector 动态分配) static vector<vector<int>> graph; // 高斯消元用的临时矩阵 M_j static vector<vector<int>> A; // 输入矩阵 A static vector<vector<int>> B; // 输入矩阵 B typedef long long ll; // 高斯消元求秩(模2),返回矩阵的秩 // 传入的 graph 会被原地修改为行最简形 static int guass(int n) { int r = 0; // r 表示当前已经确定的主元个数,也是下一个主元要放的行号 for (int i = 1; i <= n; i++) { // i 表示当前处理的列 for (int j = r + 1; j <= n; j++) { // 在剩余行中寻找主元 if (j < r + 1 && graph[j][j] == 1) { // 已处理过的对角行跳过 continue; } if (graph[j][i] == 1) { // 找到第 i 列为 1 的行 swap(graph[r + 1], graph[j]); // 把它换到第 r+1 行 break; } } if (graph[r + 1][i] == 1) { // 如果第 r+1 行第 i 列确实是主元 r++; // 主元数 +1 for (int j = 1; j <= n; j++) { // 用主元行消去其他行的第 i 列 if (j != r && graph[j][i] == 1) { // 跳过主元行自己,其他行第 i 列为 1 才消 for (int k = i; k <= n; k++) { // 从第 i 列开始异或即可 graph[j][k] ^= graph[r][k]; // 模 2 下减法就是异或 } } } } } return r; // 返回矩阵的秩 } // 快速幂:计算 a^b mod mod ll qpow(ll a, ll b, ll mod) { ll res = 1; while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; } return res; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; // 读入矩阵阶数 N // 下标从 1 开始,方便和高斯消元里的行列号对应 A.assign(n + 1, vector<int>(n + 1, 0)); B.assign(n + 1, vector<int>(n + 1, 0)); graph.assign(n + 1, vector<int>(n + 1, 0)); for (int i = 1; i <= n; i++) // 读入矩阵 A for (int j = 1; j <= n; j++) cin >> A[i][j]; for (int i = 1; i <= n; i++) // 读入矩阵 B for (int j = 1; j <= n; j++) cin >> B[i][j]; ll exp = 0; // 指数 E = Σ (N - rank(M_j)) for (int col = 1; col <= n; col++) { // 枚举 C 的每一列 j = col for (int i = 1; i <= n; i++) // 构造 M_j:先把 A 复制到 graph for (int j = 1; j <= n; j++) graph[i][j] = A[i][j]; for (int i = 1; i <= n; i++) // 对角线异或 B[i][col] graph[i][i] ^= B[i][col]; // M_j = A - diag(b_j),模 2 下减法即异或 int r = guass(n); // 对 M_j 求秩 exp += (n - r); // 累加该列贡献 N - rank } cout << qpow(2, exp, 998244353) << endl; // 输出 2^E mod 998244353 return 0; }
2023年第九届ccpc桂林站C---线性基
-
题目链接:2023年第九届ccpc桂林站C
-
题目核心思路:
text【题目描述】 给定长度为 n 的序列 a[1..n],其中 1 <= a[i] <= n。 问有多少个非空子序列(按下标选,重复元素算不同方案)满足: 选出的每个元素都能整除所有选中元素的异或和。 即:设选出的集合为 S,异或和为 g = XOR_{x in S} x, 要求对任意 x in S,都有 x | g。 答案对 998244353 取模。 【输入】 第一行:T,表示测试用例个数。 每个测试用例: 第一行:n 第二行:n 个整数 a[1..n] 【输出】 每个测试用例输出一行,一个整数,答案 mod 998244353。 【数据范围】 1 <= T <= 2e5 1 <= n <= 2e5 1 <= a[i] <= n 所有测试用例的 n 之和 <= 2e5 【样例1】 输入: 2 3 1 2 3 5 3 3 5 1 1 输出: 4 11 【思路解说】 【一、核心观察】 设 M = max(S),g 为异或和。 因为每个 x | g,所以 M | g。 又因为 g 是选中元素的异或,所以 g <= M(按最高位考虑)。 若 g != 0,则 M | g 且 g <= M 且 g >= M(因为 M | g 且 g != 0), 所以 g = M。 若 g = 0,则条件自动满足。 因此答案分为两部分: 1) 异或和为 0 的子序列数 2) 异或和为 k 且最大值为 k 的子序列数,对所有 k 求和 【二、第一部分:异或和为 0】 把所有数插入线性基。 设插入失败的次数为 c(即自由元素个数), 则异或和为 0 的子序列数为 2^c。 去掉空集,贡献为 2^c - 1。 【三、第二部分:异或和为 k,最大值为 k】 枚举所有在序列中出现过的 k。 对于固定的 k: - 选出的元素必须都是 k 的因子(因为每个元素要整除 k)。 - 最大值必须为 k,所以至少选一个 k。 - 异或和必须为 k。 设 D_k = { d | d 是 k 的因子,d != k,且 d 在序列中出现过 }。 从 D_k 中选一个子集,使其异或和为 0(允许空集)。 然后再异或奇数个 k,这样总异或和就是 k。 贡献 = (D_k 中异或和为 0 的子序列数) * (从所有 k 中选奇数个的方案数)。 D_k 中异或和为 0 的子序列数: 把 D_k 中的数插入线性基,设自由元素个数为 f, 则方案数为 2^f(允许空集)。 从所有 k 中选奇数个的方案数: 设 k 在序列中出现 cnt[k] 次, 则方案数为 2^(cnt[k] - 1)。 所以固定 k 的贡献为: 2^f * 2^(cnt[k] - 1) 【四、优化技巧】 对于同一个值 d,无论它出现多少次,插入线性基只需尝试一次: - 插入成功:d 进入基,剩余 cnt[d] - 1 个都是自由元素。 - 插入失败:d 被已有向量表示,cnt[d] 个全是自由元素。 这样每个值在每次枚举 k 时只需一次插入尝试, 总复杂度 O(n log^2 n),可以过 1s。 【五、算法流程】 1) 预处理 2 的幂。 2) 读入序列,统计每个值的出现次数 cnt[]。 3) 预处理每个数的因子列表 fac[](用筛法,O(n log n))。 4) 第一部分:把所有数插入线性基,得到自由元素个数 c, 答案加上 2^c - 1。 5) 第二部分:枚举 k = 1..n,若 cnt[k] == 0 跳过。 - 新建线性基,自由元素个数 f = 0。 - 遍历 k 的所有因子 d(d != k 且 cnt[d] > 0): 尝试插入一次 d: 成功:f += cnt[d] - 1 失败:f += cnt[d] - 贡献 = 2^f * 2^(cnt[k] - 1),累加到答案。 6) 输出答案对 998244353 取模。 【六、复杂度】 预处理因子:O(n log n) 第一部分:O(n log n) 第二部分:O(n log^2 n) 总复杂度:O(n log^2 n),n <= 2e5,可过。 【七、样例验证】 样例1: 第一组:n=3, a=[1,2,3] 第一部分:插入 1,2,3,线性基秩 2,自由元素 1 个, 贡献 2^1 - 1 = 1(对应 {1,2,3}) k=1:因子 {1},排除 1 后为空,f=0,cnt[1]=1, 贡献 2^0 * 2^0 = 1(对应 {1}) k=2:因子 {1,2},排除 2 后剩 1,插入 1 成功,f=0, cnt[2]=1,贡献 2^0 * 2^0 = 1(对应 {2}) k=3:因子 {1,3},排除 3 后剩 1,插入 1 成功,f=0, cnt[3]=1,贡献 2^0 * 2^0 = 1(对应 {3}) 总答案 = 1 + 1 + 1 + 1 = 4 ✓ 第二组:输出 11 ✓ 【八、总结】 按值域枚举最大值 k → 因子限制 + 异或和为 0 的子集计数 → 线性基求自由元素个数 → 2^f * 2^(cnt[k]-1) → 加上异或和为 0 的贡献 2^c - 1 → 预处理因子 + 每个值只插一次线性基优化 → 总复杂度 O(n log^2 n)-
代码入下:
cpp#include <bits/stdc++.h> using namespace std; const int MOD = 998244353; // 模数 const int MAXN = 200005; // 最大 n // 线性基(模 2 异或空间) struct LinearBasis { int p[20]; // p[i] 表示最高位为 i 的基向量 int cnt; // 插入失败的次数(即自由元素个数) LinearBasis() { memset(p, 0, sizeof(p)); cnt = 0; } // 插入一个数 x,返回是否插入成功(线性无关) bool insert(int x) { for (int i = 18; i >= 0; i--) { if (!(x >> i & 1)) continue; // 当前位为 0,跳过 if (!p[i]) { // 该位没有基向量,插入成功 p[i] = x; return true; } x ^= p[i]; // 否则消去当前位 } return false; // 被已有基向量线性表示,插入失败 } }; long long pw[MAXN]; // pw[i] = 2^i mod MOD int main() { ios::sync_with_stdio(false); cin.tie(nullptr); // 预处理 2 的幂 pw[0] = 1; for (int i = 1; i < MAXN; i++) pw[i] = pw[i-1] * 2 % MOD; int T; cin >> T; while (T--) { int n; cin >> n; vector<int> a(n), cnt(n + 1, 0); for (int i = 0; i < n; i++) { cin >> a[i]; cnt[a[i]]++; // 统计每个值的出现次数 } // 预处理每个数的因子列表(筛法,O(n log n)) vector<vector<int>> factors(n + 1); for (int d = 1; d <= n; d++) for (int m = d; m <= n; m += d) factors[m].push_back(d); // d 是 m 的因子 long long ans = 0; // ===== 第一部分:异或和为 0 的子序列 ===== { LinearBasis lb; int free_cnt = 0; // 自由元素个数 for (int x : a) { if (!lb.insert(x)) free_cnt++; // 插入失败,说明是自由元素 } // 异或和为 0 的非空子序列数 = 2^free_cnt - 1 ans = (ans + pw[free_cnt] - 1 + MOD) % MOD; } // ===== 第二部分:异或和为 k,最大值为 k ===== for (int k = 1; k <= n; k++) { if (cnt[k] == 0) continue; // k 没出现过,跳过 LinearBasis lb; int free_cnt = 0; // 遍历 k 的所有因子 d(排除 k 本身) for (int d : factors[k]) { if (d == k) continue; // 排除 k 本身 if (cnt[d] == 0) continue; // d 没出现过,跳过 // 对于同一个值 d,只需尝试插入一次 if (lb.insert(d)) { // 插入成功:d 进入基,剩余 cnt[d]-1 个都是自由元素 free_cnt += cnt[d] - 1; } else { // 插入失败:cnt[d] 个全是自由元素 free_cnt += cnt[d]; } } // D_k 中异或和为 0 的子序列数 = 2^free_cnt(允许空集) long long ways_zero = pw[free_cnt]; // 从所有 k 中选奇数个的方案数 = 2^(cnt[k]-1) long long ways_odd_k = pw[cnt[k] - 1]; // 贡献累加 ans = (ans + ways_zero * ways_odd_k) % MOD; } cout << ans << '\n'; } return 0; }
-
第45届国际大学生编程设计竞赛沈阳站I---同余原理
- 题目链接:第45届国际大学生编程设计竞赛沈阳站I
- 题目描述及思路如下:
text
Rise of Shadows(2020 ICPC 沈阳站 I / CF Gym 103202 I)
【题目描述】
在 Azeroth 世界中,一天有 H 个小时,1 小时有 M 分钟。
你发现了一个由 Azeroth 制造的时钟,它有两根指针 ------ 时针和分针。
在一天的开始时刻,两根指针指向同一个方向。
每根指针都以恒定速度旋转:
- 时针在 H 小时内转一整圈;
- 分针在 M 分钟内转一整圈。
当且仅当两根指针之间的夹角小于或等于 α 时,Azeroth 是夜晚。
现在给定 α = 2πA / (HM),问:在一天中,有多少个整数时刻(即从一天开始经过的整数分钟数),
使得两根指针之间的夹角小于或等于 α。
【输入】
仅一行:三个整数 H, M(2 ≤ H, M ≤ 10^9)和 A(0 ≤ A ≤ HM²),分别表示
一天的小时数、一小时的分钟数、角度限制参数。
【输出】
输出一个整数,表示满足条件的整数时刻数量。
【样例1】
输入:
5 5 4
输出:
9
【样例2】
输入:
3 5 1
输出:
3
【思路解说】
【一、核心观察:转化为同余不等式】
设从一天开始经过了 t 分钟(t ∈ [0, HM))。
- 时针的角度为 2πt / (HM);
- 分针的角度为 2πt / M mod 2π。
两针的夹角为:
Δθ = (2πt / M - 2πt / (HM)) mod 2π
= 2πt(H - 1) / (HM) mod 2π
题目要求 Δθ ≤ 2πA / (HM),化简得:
t(H - 1) mod HM ≤ A
这就是一个同余不等式问题。
【二、用剩余系求解】
设 g = gcd(H - 1, HM)。把同余式两边同时除以 g:
t · (H - 1)/g mod (HM / g) ≤ floor(A / g)
因为 (H - 1)/g 与 HM/g 互质,所以当 t 取遍 0 到 HM/g - 1 时,
t · (H - 1)/g mod (HM / g) 会取遍所有余数,且一一对应。
因此,在一个长度为 HM/g 的周期内,满足条件的余数有:
floor(A / g) + 1 个(即 0, 1, ..., floor(A/g))
整个一天共有 g 个这样的完整周期。
【三、正反方向合并】
上面的分析只考虑了正方向(夹角 ≤ α)。但两根指针的夹角是 0 到 π 之间的较小角,
所以还要考虑反方向(夹角 ≥ 2π - α,即从另一侧看夹角也 ≤ α)。
反方向对应的条件是:
t(H - 1) mod HM ≥ HM - A
在同样的剩余系下,满足这个条件的余数个数为:
HM/g - ceil((HM - A) / g)
所以最终答案是:
ans = g · (floor(A/g) + 1) + g · (HM/g - ceil((HM - A)/g))
【四、特判】
当 2A ≥ HM 时,任何时刻两针的夹角都不会超过 α,直接输出 HM。
【五、算法流程】
1) 读入 H, M, A。
2) 计算 HM = H * M。
3) 如果 2A ≥ HM,输出 HM,结束。
4) 计算 g = gcd(H - 1, HM)。
5) 计算正方向贡献:positive = g * (A / g + 1)。
6) 计算反方向贡献:negative = g * (HM / g - (HM - A + g - 1) / g)。
7) 输出 positive + negative。
【六、复杂度】
- 仅一次 gcd 运算,时间复杂度 O(log min(H, M))。
- 空间复杂度 O(1)。
【七、注意事项】
- 特判 2A ≥ HM 的情况,否则会重复计算。
- 反方向贡献中的 ceil 用 (x + g - 1) / g 实现。
- 所有变量用 long long,因为 H, M 可达 10^9,HM 可达 10^18。
- 取模运算中注意负数情况,但本题公式已避免负数。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
class RiseOfShadows {
public:
// 求解满足条件的整数时刻数量
// H: 一天的小时数, M: 一小时的分钟数, A: 角度限制参数
ll solve(ll H, ll M, ll A) {
ll HM = H * M; // 一天的总分钟数
// ===== 特判:如果 A 足够大,覆盖整个一天 =====
// 当 2A >= HM 时,任何时刻两针的夹角都不会超过 α
if (2 * A >= HM) return HM;
// ===== 计算 g = gcd(H - 1, HM) =====
// g 是剩余系的周期数
ll g = gcd(H - 1, HM);
// ===== 正方向贡献 =====
// 条件:t*(H-1) mod HM <= A
// 在一个长度为 HM/g 的周期内,满足条件的余数有 A/g + 1 个
// 整个一天共有 g 个完整周期
ll positive = g * (A / g + 1);
// ===== 反方向贡献 =====
// 条件:t*(H-1) mod HM >= HM - A
// 即余数落在 [HM-A, HM-1]
// 在一个周期内,满足条件的余数个数为:
// HM/g - ceil((HM - A) / g)
// 其中 ceil(x / g) 用 (x + g - 1) / g 实现
ll negative = g * (HM / g - (HM - A + g - 1) / g);
// ===== 总答案 =====
return positive + negative;
}
private:
// 欧几里得算法求最大公约数
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
ll H, M, A;
cin >> H >> M >> A;
RiseOfShadows solver;
cout << solver.solve(H, M, A) << '\n';
return 0;
}
2020年CCPC秦皇岛站A---组合数学
- 签到题链接:2020年CCPC秦皇岛站A
- 题目描述及思路如下:
text
Greeting from Qinhuangdao(2020 CCPC 秦皇岛站 A / CF Gym 102769 A)
【题目描述】
有 r 个红气球和 b 个蓝气球。现在从所有气球中**随机抽取两个**。
求抽到的两个气球**都是红色**的概率,结果用最简分数表示。
如果概率为 0,输出 0/1;如果概率为 1,输出 1/1。
【输入】
第一行:T,测试用例个数。
每个测试用例:一行两个整数 r, b(0 ≤ r, b ≤ 10^9)。
【输出】
对于每个测试用例,输出一行 "Case #x: p/q",其中 x 是测试用例编号(从 1 开始),
p/q 是最简分数形式的概率。
【样例1】
输入:
3
1 1
2 1
8 8
输出:
Case #1: 0/1
Case #2: 1/3
Case #3: 7/30
【思路解说】
【一、古典概型】
这是一个非常基础的古典概型问题。所有可能的抽取结果等概率,我们只需要计算:
- 总方案数:从 r + b 个气球中任选 2 个。
- 有利方案数:从 r 个红气球中任选 2 个。
【二、组合数计算】
总方案数 = C(r + b, 2) = (r + b)(r + b - 1) / 2
有利方案数 = C(r, 2) = r(r - 1) / 2
概率 = 有利方案数 / 总方案数
= [r(r - 1) / 2] / [(r + b)(r + b - 1) / 2]
= r(r - 1) / [(r + b)(r + b - 1)]
【三、特判】
如果 r < 2,则无法抽出两个红球,概率为 0/1。
如果 b = 0 且 r ≥ 2,则概率为 1/1。
【四、约分】
用 gcd 求出分子分母的最大公约数,然后分别除以 gcd 得到最简分数。
【五、算法流程】
1) 读入 T。
2) 对于每个测试用例:
- 读入 r, b。
- 如果 r < 2,输出 "Case #x: 0/1"。
- 否则:
- 分子 numerator = r * (r - 1)
- 分母 denominator = (r + b) * (r + b - 1)
- g = gcd(numerator, denominator)
- 输出 "Case #x: numerator/g / denominator/g"
3) 结束。
【六、复杂度】
- 每个测试用例 O(log V),V 为数值范围。
- 总复杂度 O(T log V),可以过。
【七、注意事项】
- r 可能为 0 或 1,需要特判。
- 分子分母可能很大,要用 long long。
- 输出格式严格为 "Case #x: p/q",注意空格和斜杠。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
class BallProbability {
public:
void solve() {
int T;
cin >> T;
// 遍历每个测试用例
for (int i = 1; i <= T; i++) {
ll r, b;
cin >> r >> b;
// 特判:红球少于 2 个,无法抽出两个红球,概率为 0
if (r < 2) {
cout << "Case #" << i << ": 0/1" << endl;
continue;
}
// 分子:从 r 个红球中选 2 个的方案数
// C(r, 2) = r * (r - 1) / 2
// 因为概率公式中分子分母都有除以 2,可以约掉,所以这里只算 r * (r - 1)
ll numerator = r * (r - 1);
// 分母:从 r + b 个球中选 2 个的方案数
// C(r + b, 2) = (r + b) * (r + b - 1) / 2
ll denominator = (r + b) * (r + b - 1);
// 求分子分母的最大公约数,用于约分
ll g = gcd(numerator, denominator);
// 输出最简分数形式
cout << "Case #" << i << ": " << numerator / g << "/" << denominator / g << endl;
}
}
private:
// 欧几里得算法求最大公约数
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
};
int main() {
BallProbability solver;
solver.solve();
return 0;
}
第七届中国大学生编程设计竞赛威海站M---容斥原理+组合计数
- 题目链接:第七届中国大学生编程设计竞赛威海站M
- 题目描述及思路如下:
text
810975(2021 CCPC 威海站 M / CF Gym 103428 M)
【题目描述】
在鸭嘴兽王国,有一款风靡全国的游戏 ------ 炉石传说。
一局炉石传说有 8 名玩家,只有 1 人能获胜。
最近,一句圣经 "810975" 流传开来。
810975 的含义是:8 月 10 日,鸭嘴兽国王打了 9 局,赢了 7 局,
其中包含一波 5 连胜。
现在有一个新问题:如果打了 n 局,赢了 m 局,且**最长连胜恰好为 k**,
有多少种不同的情况?
用 1 表示胜利,0 表示失败。长度为 n 的 01 串表示一种情况。
两个情况不同当且仅当两个 01 串不同。
【输入】
一行三个整数 n, m, k(0 ≤ n, m, k ≤ 10^5)。
【输出】
一行一个整数,表示满足条件的情况数对 998244353 取模。
【样例1】
输入:
9 7 5
输出:
9
【样例2】(来自题目背景)
n=9, m=7, k=5 时答案为 9,因为 9 局赢 7 局且最长连胜恰好为 5 的 01 串共有 9 种。
【思路解说】
【一、问题转化:将 01 串分成若干段连续的 1】
长度为 n 的 01 串中有 m 个 1 和 n-m 个 0。
这 m 个 1 被 0 分成了若干段连续的 1。
具体来说,两个连续的 0 之间的部分是一段 1,开头和结尾也可能有长度为 0 的段。
一共有 **n - m + 1** 段连续的 1(包括长度为 0 的段)。
于是问题转化为:
求 n - m + 1 个非负整数 x₁, x₂, ..., x_{n-m+1},
满足 x₁ + x₂ + ... + x_{n-m+1} = m,
且每个 xᵢ ≤ k 的方案数。
【二、容斥原理:先不考虑 ≤ k 的限制】
不考虑 xᵢ ≤ k 的限制时,方程的非负整数解个数是经典的隔板法:
C(m + (n-m+1) - 1, (n-m+1) - 1) = C(n, n-m)
现在要减去"至少有一段超过 k"的情况。
用容斥原理:
钦定 i 段长度 ≥ k+1,即先从 m 个 1 中拿出 i(k+1) 个,
分别放入这 i 段中(每段至少 k+1 个),剩下的 m - i(k+1) 个 1 自由分配。
所以:
f(k) = Σ_{i=0}^{⌊m/(k+1)⌋} (-1)^i · C(n-m+1, i) · C(n - i(k+1), n-m)
其中:
- C(n-m+1, i):从 n-m+1 段中选出 i 段,让它们超过 k
- C(n - i(k+1), n-m):剩下的 1 自由分配的方案数
【三、恰好为 k 的答案】
设 f(k) 表示最长连续 1 的长度**不超过 k** 的方案数。
那么恰好为 k 的答案就是:
ans = f(k) - f(k-1)
【四、特判】
- 当 k = 0 时,不能有任何连续的 1,只有 m = 0 时才合法(全 0 串),返回 1;否则返回 0。
- 当 m = 0 时,没有任何 1,只有一种情况(全 0 串),返回 1。
【五、算法流程】
1) 读入 n, m, k。
2) 特判 k = 0 和 m = 0 的情况。
3) 预处理阶乘和阶乘逆元(到 n+5)。
4) 计算 f(k) 和 f(k-1)。
5) 输出 (f(k) - f(k-1) + MOD) % MOD。
【六、复杂度】
- 预处理阶乘:O(n)
- 每次计算 f(k):O(m / (k+1)),因为容斥循环次数不超过 m/(k+1)
- 总复杂度:O(n + m/k + m/(k-1)),对于 n, m, k ≤ 10^5 可以过。
【七、注意事项】
- 阶乘和逆元要预处理到 n+5,因为 C(n - i(k+1), n-m) 中 n - i(k+1) 可能接近 n。
- 组合数 C(n, k) 中若 k < 0 或 k > n,直接返回 0。
- 容斥的循环上界是 m / (k+1),超过就没有贡献。
- 答案对 998244353 取模,减法后要 + MOD 再 % MOD。
- 多组测试要清空数组。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MOD = 998244353; // 模数
const int MAXN = 200005; // 阶乘表大小
class Solve810975 {
ll fac[MAXN], inv_fac[MAXN]; // 阶乘表、阶乘逆元表
public:
// 预处理阶乘和阶乘逆元
void init(int n) {
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i-1] * i % MOD;
// 费马小定理求 n! 的逆元
inv_fac[n] = pow_mod(fac[n], MOD - 2);
// 线性递推求所有阶乘的逆元
for (int i = n - 1; i >= 0; i--) inv_fac[i] = inv_fac[i+1] * (i+1) % MOD;
}
// 组合数 C(n, k) mod MOD
ll C(int n, int k) {
if (k < 0 || k > n) return 0;
return fac[n] * inv_fac[k] % MOD * inv_fac[n-k] % MOD;
}
// f(k): 最长连续 1 的长度不超过 k 的方案数
// 将问题转化为 n-m+1 个非负整数变量之和为 m,每个变量 <= k
// 容斥原理:sum_{i} (-1)^i * C(n-m+1, i) * C(n - i*(k+1), n-m)
ll f(int n, int m, int k) {
ll res = 0;
// 枚举 i:钦定 i 段长度 >= k+1
// 每钦定一段,至少要分配 k+1 个 1,所以 i*(k+1) <= m
for (int i = 0; i * (k + 1) <= m; i++) {
// C(n-m+1, i):从 n-m+1 段中选出 i 段
// C(n - i*(k+1), n-m):剩余 1 自由分配的方案数
ll term = C(n - m + 1, i) * C(n - i * (k + 1), n - m) % MOD;
// 容斥符号:奇数项减,偶数项加
if (i & 1) res = (res - term + MOD) % MOD;
else res = (res + term) % MOD;
}
return res;
}
// 主求解函数
ll solve(int n, int m, int k) {
// 特判:k = 0 时不能有任何连续的 1,只有 m = 0 才合法
if (k == 0) return (m == 0) ? 1 : 0;
// 特判:m = 0 时没有任何 1,只有一种情况(全 0)
if (m == 0) return 1;
init(n + 5);
// 恰好为 k = 不超过 k - 不超过 k-1
return (f(n, m, k) - f(n, m, k - 1) + MOD) % MOD;
}
private:
// 快速幂:计算 a^b mod MOD
ll pow_mod(ll a, ll b) {
ll res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, k;
cin >> n >> m >> k;
Solve810975 solver;
cout << solver.solve(n, m, k) << '\n';
return 0;
}
2024年CCPC中国大学生编程设计竞赛重庆站A---质数筛
- 题目链接:2024年CCPC中国大学生编程设计竞赛重庆站A
- 题目描述及思路:
text
乘积,欧拉函数,求和(CCPC 2024 重庆站 A / QOJ 9619)
【题目描述】
给定长度为 n 的数组 a,求:
Σ_{S ⊆ {1,2,...,n}} φ( ∏_{i∈S} a_i )
即**所有子集(包含空集)** 的元素乘积的欧拉函数值之和,答案对 998244353 取模。
【输入】
第一行:n(1 ≤ n ≤ 2000)
第二行:n 个整数 a_i(1 ≤ a_i ≤ 3000)
【输出】
一行一个整数,表示答案对 998244353 取模。
【样例1】
输入:
3
1 2 3
输出:
12
【样例1解释】
所有子集(含空集):
- 空集:乘积 1,φ(1) = 1
- {1}:乘积 1,φ(1) = 1
- {2}:乘积 2,φ(2) = 1
- {3}:乘积 3,φ(3) = 2
- {1,2}:乘积 2,φ(2) = 1
- {1,3}:乘积 3,φ(3) = 2
- {2,3}:乘积 6,φ(6) = 2
- {1,2,3}:乘积 6,φ(6) = 2
总和 = 1+1+1+2+1+2+2+2 = 12
【思路解说】
【一、欧拉函数的积性拆分】
利用欧拉函数公式:
φ(m) = m · ∏_{p | m} (p - 1) / p
因此:
φ( ∏_{i∈S} a_i ) = ( ∏_{i∈S} a_i ) · ∏_{p | ∏_{i∈S} a_i} (p - 1) / p
也就是说,一个集合的贡献 = 集合元素乘积 × 集合中所有元素质因子并集的 (p-1)/p 乘积。
【二、根号分治:小质数状压 + 大质数分组】
因为 a_i ≤ 3000,质因子可以分成两类:
- 小质数:≤ 53 的质数(共 16 个:2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53)
- 大质数:> 53 的质数
每个 a_i 最多含有一个大质数,因为两个 > 53 的质数乘积 > 3000。
小质数用**状压**(16 位掩码)记录;大质数按值分组处理。
【三、FST(快速子集变换 / 子集和变换)域中的或卷积】
设 f[S] 表示"质因子并集恰好为 S"的集合的贡献(乘积之和)。
朴素的状压 DP 是 O(n · 2^16),转移为或卷积:
f[S | T] += f[S] · a_i
直接转移总复杂度 O(n · 2^16) = 2000 · 65536 ≈ 1.3 × 10^8,需要优化。
**关键观察**:或卷积在 FST 域中变成**逐点乘法**。
定义 FST(子集和变换):
FST(f)[M] = Σ_{S ⊆ M} f[S]
在 FST 域中:
FST(f ⊗ g) = FST(f) · FST(g) (逐点相乘)
所以我们可以:
1) 对 base(不含大质数的元素组成的贡献)做一次正变换。
2) 对每个大质数分组,计算组内贡献的 FST,累乘到 B 上。
3) 最后做一次逆变换,得到真实的 f[S]。
【四、大质数分组的处理】
对于大质数 q:
- 设组内贡献为 G_q(含空集,即 G_q[0] = 1)。
- 考虑大质数 q 的 (q-1)/q 贡献:用 r_q = (q-1)/q 表示。
- 组内元素可以选择"不选"(贡献为 δ,即只在 0 处为 1)或"选至少一个"(贡献为 G_q - δ)。
- 所以该组的总贡献为:
δ + r_q · (G_q - δ)
在 FST 域中,δ 变换后恒为全 1 向量。因此:
FST( δ + r_q · (G_q - δ) ) = 1 + r_q · ( FST(G_q) - 1 )
逐点乘到 B 上即可。
【五、算法流程】
1) 用线性筛预处理 3000 以内的最小质因子 spf。
2) 取 ≤ 53 的质数作为小质数(16 个),建立 idxOf 映射。
3) 对每个 a_i 分解质因子:
- 小质数部分 → 状压掩码 msk[i]
- 大质数部分 → bq[i](没有则为 0)
4) 计算 base:不含大质数的元素做 0/1 背包(或卷积),得到 base[S]。
5) 对 base 做 FST 正变换,得到 B。
6) 按大质数分组:
- 对每组,组内做 0/1 背包得到 G。
- 对 G 做 FST 正变换。
- 逐点累乘:B[M] *= 1 + r_q · (G[M] - 1)。
7) 对 B 做 FST 逆变换,得到最终 f[S]。
8) 汇总答案:
ans = Σ_S f[S] · ∏_{i: S 第 i 位为 1} (smallP[i]-1)/smallP[i]
【六、复杂度】
- 元素分解:O(n log A)
- base 背包:O(n · 2^16)
- FST 正/逆变换:O(16 · 2^16) ≈ 10^6
- 每个大质数分组:组内背包 + FST
- 总复杂度:O((n + 16) · 2^16),可以过。
【七、注意事项】
- FST 正变换:for b in 0..K-1: for M: if M 第 b 位为 1: B[M] += B[M ^ (1<<b)]
- FST 逆变换:for b in 0..K-1: for M: if M 第 b 位为 1: B[M] -= B[M ^ (1<<b)]
- 大质数的 (q-1)/q 贡献在 FST 域中用 r_q 表示,且空集 δ 变换后为全 1 向量。
- 小质数的 (p-1)/p 贡献在最后汇总时乘上。
- 所有运算对 998244353 取模。
- 用 long long 防止乘法溢出。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
static const int MOD = 998244353;
static const int MAXA = 3000;
// 快速幂:计算 b^e mod MOD
static inline ll qpow(ll b, ll e) {
ll r = 1; b %= MOD;
while (e) {
if (e & 1) r = r * b % MOD;
b = b * b % MOD;
e >>= 1;
}
return r;
}
// 模 MOD 下的逆元(MOD 为质数,用费马小定理)
static inline ll inv(ll x) {
return qpow((x % MOD + MOD) % MOD, MOD - 2);
}
int spf[MAXA + 1]; // 最小质因子(smallest prime factor)
vector<int> primes; // 3000 以内的所有质数
// 线性筛:预处理 3000 以内的最小质因子
void sieve() {
for (int i = 2; i <= MAXA; i++) {
if (!spf[i]) { spf[i] = i; primes.push_back(i); }
for (int p : primes) {
if ((ll)p * i > MAXA) break;
spf[p * i] = p;
if (p == spf[i]) break;
}
}
}
int main() {
sieve();
// ===== 小质数:≤ 53 的质数,共 16 个 =====
vector<int> smallP;
for (int p : primes) if (p <= 53) smallP.push_back(p);
int K = smallP.size(); // 16
int SM = 1 << K; // 65536,状压掩码总数
// 小质数到掩码位下标的映射
int idxOf[64];
memset(idxOf, -1, sizeof(idxOf));
for (int i = 0; i < K; i++) idxOf[smallP[i]] = i;
// 大质数 q 的 (q-1)/q 系数
vector<int> ratioBig(MAXA + 1, 1);
for (int q : primes)
if (q > 53)
ratioBig[q] = (ll)(q - 1) % MOD * inv(q) % MOD;
// ===== 读入 =====
int n;
if (scanf("%d", &n) != 1) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
// ===== 分解每个 a_i =====
// msk[i]:小质数掩码
// bq[i] :大质数(没有则为 0)
vector<int> msk(n, 0), bq(n, 0);
for (int i = 0; i < n; i++) {
int x = a[i];
while (x > 1) {
int p = spf[x];
while (x % p == 0) x /= p;
if (p <= 53) msk[i] |= 1 << idxOf[p];
else bq[i] = p;
}
}
// ===== 不含大质数的元素:直接做 0/1 背包(稠密) =====
// base[S]:只考虑不含大质数的元素,质因子并集为 S 的所有集合的乘积之和
vector<ll> base(SM, 0), nf(SM, 0);
base[0] = 1;
for (int i = 0; i < n; i++) {
if (bq[i]) continue;
int m = msk[i];
ll v = a[i] % MOD;
memcpy(nf.data(), base.data(), sizeof(ll) * SM);
for (int M = 0; M < SM; M++) {
if (base[M]) {
int NM = M | m;
nf[NM] = (nf[NM] + base[M] * v) % MOD;
}
}
base.swap(nf);
}
// ===== 按大质数分组 =====
map<int, vector<pair<int, ll>>> groups;
for (int i = 0; i < n; i++)
if (bq[i])
groups[bq[i]].push_back({msk[i], (ll)a[i] % MOD});
// ===== 在 FST 域内累乘 =====
// 目标:ans_vector = base ⊗ Π_q [ δ + r_q (G_q - δ) ]
// 在 FST(子集和)域中,或卷积 = 逐点乘法
// 记 B = FST(base),则结果 = IFST( B * Π_q [ 1 + r_q (FST(G_q) - 1) ] )
vector<ll> B(SM, 0);
memcpy(B.data(), base.data(), sizeof(ll) * SM);
// 对 base 做 FST 正变换
for (int b = 0; b < K; b++)
for (int M = 0; M < SM; M++)
if (M >> b & 1)
B[M] = (B[M] + B[M ^ (1 << b)]) % MOD;
vector<ll> G(SM, 0);
// 逐个大质数分组处理
for (auto& kv : groups) {
int q = kv.first;
ll r = ratioBig[q];
// ---- 组内背包(稀疏维护)----
// G[S]:只考虑当前大质数 q 组内的元素,小质数并集为 S 的贡献
vector<int> cur;
cur.push_back(0);
fill(G.begin(), G.end(), 0);
G[0] = 1;
for (auto& pr : kv.second) {
int m = pr.first;
ll v = pr.second;
vector<pair<int, ll>> add;
add.reserve(cur.size());
for (int M : cur)
add.push_back({M | m, G[M] * v % MOD});
for (auto& ad : add) {
if (G[ad.first] == 0) cur.push_back(ad.first);
G[ad.first] = (G[ad.first] + ad.second) % MOD;
}
}
// 空选项在 G[0] 中,保持为 1(因为背包含空选)
// ---- 对 G 做 FST 正变换 ----
for (int b = 0; b < K; b++)
for (int M = 0; M < SM; M++)
if (M >> b & 1)
G[M] = (G[M] + G[M ^ (1 << b)]) % MOD;
// ---- 累乘:B *= (1 + r*(G - 1)) ----
// 在 FST 域中,δ(空选)变换后恒为全 1 向量
for (int M = 0; M < SM; M++) {
ll factor = (1 + r * ((G[M] - 1 + MOD) % MOD)) % MOD;
B[M] = B[M] * factor % MOD;
}
}
// ---- 对 B 做 FST 逆变换 ----
for (int b = 0; b < K; b++)
for (int M = 0; M < SM; M++)
if (M >> b & 1)
B[M] = (B[M] - B[M ^ (1 << b)] + MOD) % MOD;
// ===== 汇总:乘小质数因子 =====
// 小质数 p 的 (p-1)/p 系数
vector<ll> coef(K);
for (int i = 0; i < K; i++)
coef[i] = (ll)(smallP[i] - 1) % MOD * inv(smallP[i]) % MOD;
ll ans = 0;
for (int M = 0; M < SM; M++) {
if (!B[M]) continue;
ll cur = B[M];
for (int i = 0; i < K; i++)
if (M >> i & 1)
cur = cur * coef[i] % MOD;
ans = (ans + cur) % MOD;
}
printf("%lld\n", ans);
return 0;
}
2025年CCPC哈尔滨站A---GCD运算
- 题目链接:2025年CCPC哈尔滨站A
- 题目描述及思路如下:
text
Another GCD Problem(The 50th Petrozavodsk Programming Camp, Winter 2026, Day 4 Harbin Contest A / QOJ 14814)
【题目描述】
给定一个包含 n 个不同正整数的无限多重集 S,其中元素为 a₁, a₂, ..., aₙ。
每个元素都可以被无限次使用。
定义函数 f(k):从 S 中选出恰好 k 个元素(可重复),
计算所有可能的选取方案的和,f(k) 等于所有这些和的**最大公约数**。
形式化地:
f(k) = gcd_{s ⊆ S, |s| = k} ( Σ_{x ∈ s} x )
求 f(k) 的**最大值**,以及达到该最大值的**最小 k**。
特别地,如果最大值不存在(即 f(k) 可以无限增长),输出 `infinite`。
【输入】
第一行:t(1 ≤ t ≤ 4 × 10⁵),测试用例个数。
每个测试用例:
第一行:n(1 ≤ n ≤ 10⁵),表示集合中不同元素的个数。
第二行:n 个整数 a₁, ..., aₙ(1 ≤ aᵢ ≤ 10¹⁸)。
保证所有测试用例的 n 之和不超过 4 × 10⁵。
【输出】
对于每个测试用例:
- 如果 f(k) 的最大值存在,输出一行两个整数:最大值 和 达到该最大值的最小 k。
- 否则输出 `infinite`。
【样例1】
输入:
2
2
3 6
2
2 2
输出:
3 1
infinite
【样例2】
输入:
2
3
1 4 7
4
4 16 28 34
输出:
3 3
6 3
【思路解说】
【一、f(k) 的闭式:用差分简化】
设去重排序后最小元素为 a₁,令
D = gcd(a₂ - a₁, a₃ - a₁, ..., aₙ - a₁)
**关键结论**:
f(k) = gcd(k · a₁, D)
**证明思路**:
- 任意两个「恰好 k 个元素」的子多重集之和的差,形如 Σ dᵢ · aᵢ 且 Σ dᵢ = 0。
- 这类组合生成的理想恰好是 gcd(aᵢ - a₁) = D 的倍数集。
- 因此所有和可以写成 k · a₁ + m · D 的形式。
- 它们的最大公约数就是 gcd(k · a₁, D)。
【二、最大值与最小 k】
根据 f(k) = gcd(k · a₁, D):
**情况 1:D = 0**(所有元素相同)
- 此时 f(k) = k · a₁,随 k 无限增长,最大值不存在。
- 输出 `infinite`。
**情况 2:D > 0**
- f(k) 的最大值为 D,当 k = D / gcd(a₁, D) 时可以达到。
- **最小 k 的计算**:设 D = ∏ p^{e_p},a₁ = ∏ p^{s_p},则需要
v_p(k) ≥ e_p - s_p
即 k = ∏ p^{max(0, e_p - s_p)} = D / ∏ p^{min(e_p, s_p)}
- 其中 ∏ p^{min(e_p, s_p)} 正是 gcd(D, a₁^∞),可用**迭代 gcd** 求出:
d = D, a = a₁, g = 1
while (t = gcd(d, a)) > 1:
g *= t
d /= t
a /= t
```
-
最终 k = D / g。
-
这样无需质因子分解,只需 O(log²D) 次 gcd 运算。
【三、算法流程】
- 读入 n 和数组 a。
- 排序去重,得到唯一的元素集合。
- 如果去重后只剩一个元素(所有数相同),输出
infinite。 - 取 a₁ = a0,计算 D = gcd(a1-a0, a2-a0, ..., an-1-a0)。
- 如果 D == 0,输出
infinite。 - 用迭代 gcd 计算 g = gcd(D, a₁^∞):
- d = D, aq = a₁, g = 1
- while true:
t = gcd(d, aq)
if t == 1: break
g *= t
d /= t
aq /= t
if d == 1: break
- 输出 D 和 D / g。
【四、复杂度】
- 排序去重:O(n log n)
- 计算 D:O(n log A)
- 迭代 gcd:O(log²D)
- 总复杂度:O(n log n + n log A),可以过。
【五、注意事项】
-
如果所有元素相同,f(k) = k · a₁ 无限增长,输出
infinite。 -
去重后只剩一个元素,也视为所有元素相同。
-
D 可能为 0(所有数相同),需要特判。
-
迭代 gcd 是 O(log²D) 的,比质因子分解更快。
- 基于以上代码,思路如下:
```cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
int t;
if (scanf("%d", &t) != 1) return 0; // 读入测试用例数
while (t--) {
int n;
scanf("%d", &n);
vectora(n);
for (int i = 0; i < n; ++i) scanf("%lld", &a[i]);
// 排序 + 去重:题目保证元素互不相同,但去重更保险
sort(a.begin(), a.end());
a.erase(unique(a.begin(), a.end()), a.end());
// ===== 特判:所有元素相同 =====
// 此时 D = 0,f(k) = k * a1,随 k 无限增长,无最大值
if (a.size() == 1) {
puts("infinite");
continue;
}
// ===== 计算 D = gcd(a2 - a1, a3 - a1, ..., an - a1) =====
ll a1 = a[0];
ll D = 0;
for (size_t i = 1; i < a.size(); ++i) {
D = __gcd(D, a[i] - a1);
}
// D = 0 说明所有元素都等于 a1(但前面已特判,这里作为保险)
if (D == 0) {
puts("infinite");
continue;
}
// ===== 计算 g = gcd(D, a1^∞) = ∏ p^min(v_p(D), v_p(a1)) =====
// 迭代 gcd:不断提取 D 和 a1 的公共质因子部分
// 例如 D = 12 = 2²·3,a1 = 18 = 2·3²,则 g = 2·3 = 6
ll d = D; // 剩余待分解的 D
ll aq = a1; // 剩余待分解的 a1
ll g = 1; // 累积的公共部分
while (true) {
ll t = __gcd(d, aq); // 当前公共质因子部分
if (t == 1) break; // 没有公共质因子了,停止
g *= t; // 累乘公共部分
d /= t; // 从 D 中除去公共部分
aq /= t; // 从 a1 中除去公共部分
if (d == 1) break; // D 已经完全分解
}
// ===== 输出:最大值 D,最小 k = D / g =====
printf("%lld %lld\n", D, D / g);
}
return 0;
}
- 基于以上代码,思路如下:
第49届亚洲网络预选赛L---数学期望
- 题目链接:第49届亚洲网络预选赛L
- 他的题目描述和思路如下:
text
502 Bad Gateway(Codeforces Gym 105358 L / ICPC 网络连接练习赛)
【题目描述】
每支队伍有一个计时器。比赛开始时(第 0 秒),系统会均匀随机地在 [1, T] 中
生成一个整数 t0,并把它设为计时器的剩余时间。
接下来,在每一秒结束时(从第 0 秒结束开始),会依次发生:
1. 计时器的剩余时间减 1。
- 如果剩余时间变为 0,游戏结束。从比赛开始到这一刻经过的秒数就是你的惩罚。
- 否则,你可以选择:
(a) 什么都不做;
(b) 对 OMS 使用一次刷新操作。OMS 会均匀随机地在 [1, T] 中生成一个新的整数 t',
并把计时器的剩余时间重新设为 t'。
你始终知道计时器的当前剩余时间,也知道 T 的值。
你的目标是采用最优策略,使期望惩罚最小。
给定若干组 T,对每组输出最优期望惩罚的最简分数 x / y。
【输入】
第一行一个整数 n(1 ≤ n ≤ 10^6),表示测试用例数。
接下来 n 行,每行一个整数 Ti(1 ≤ Ti ≤ 10^9)。
【输出】
每个测试用例输出一行两个正整数 xi, yi,满足 gcd(xi, yi) = 1,
表示最优期望惩罚为 xi / yi。
【样例1】
输入:
3
1
2
3
输出:
1 1
3 2
2 1
【思路解说】
【一、状态定义】
设 E[t] 表示当前剩余时间为 t 时,采用最优策略的期望惩罚(从当前时刻开始算,
包括当前这一秒结束时的操作)。
【二、决策分析】
当剩余时间为 t = 1 时:
- 减 1 后变为 0,游戏结束,惩罚为 1。
- 所以 E[1] = 1。
当剩余时间为 t > 1 时:
- 减 1 后剩余 t - 1,此时有两种选择:
(a) 不刷新:继续游戏,期望惩罚 = 1 + E[t - 1]。
(b) 刷新:重新随机生成 t' ∈ [1, T],期望惩罚 = 1 + S,
其中 S = (1 / T) * Σ_{t'=1}^{T} E[t']。
最优策略是取较小值:
E[t] = 1 + min( E[t - 1], S )
【三、阈值结构】
由于 E[t] 随 t 递减,存在一个阈值 k:
- 当 t > k 时,E[t - 1] > S,刷新更优;
- 当 t ≤ k 时,E[t - 1] ≤ S,不刷新更优。
于是:
E[t] = t 当 1 ≤ t ≤ k
E[t] = 1 + S 当 k < t ≤ T
【四、求解 S 与 k】
由 S = (1 / T) * Σ_{t=1}^{T} E[t],代入上面的分段表达式:
S = (1 / T) * [ k(k+1)/2 + (T - k)(1 + S) ]
整理得:
S = (k + 1) / 2 + (T - k) / k
同时阈值 k 必须满足:
k ≤ 1 + S 且 1 + S < k + 1
代入 S 的表达式,化简得:
k^2 - k ≤ 2T
所以 k 是满足 k^2 - k ≤ 2T 的最大整数,即:
k = floor( (1 + sqrt(1 + 8T)) / 2 )
【五、最终答案】
初始 t0 在 [1, T] 中均匀随机,所以期望惩罚就是 S:
S = (k^2 - k + 2T) / (2k)
输出 S 的最简分数形式 x / y。
【六、算法流程】
(1) 读入 T。
(2) 计算 k = floor( (1 + sqrt(1 + 8T)) / 2 ),并用 while 微调确保 k^2 - k ≤ 2T。
(3) 计算分子 num = k^2 - k + 2T,分母 den = 2k。
(4) 用 gcd 约分。
(5) 输出 num / den。
【七、复杂度】
每个测试用例 O(1)(或 O(log T) 微调 k),
总复杂度 O(n),n ≤ 10^6,可以过。
【八、样例验证】
T = 1:k = 1,S = (1 - 1 + 2) / 2 = 1,输出 1 / 1。
T = 2:k = 2,S = (4 - 2 + 4) / 4 = 6 / 4 = 3 / 2,输出 3 / 2。
T = 3:k = 2,S = (4 - 2 + 6) / 4 = 8 / 4 = 2,输出 2 / 1。
完全符合样例。
【九、注意事项】
- T 可达 10^9,k 约 sqrt(2T) ≈ 44721,k^2 约 2 × 10^9,需用 long long。
- 计算 k 时用 sqrt 可能因浮点误差偏大或偏小,需要 while 微调。
- 输出前用 gcd 约分。
- 多组测试直接循环处理,无需清空数组。
- 根据以上思路代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
int n;
cin >> n; // 测试用例个数
while (n--) {
ll T;
cin >> T;
// 计算阈值 k
// 由 k^2 - k <= 2T 得 k = floor((1 + sqrt(1 + 8T)) / 2)
ll k = (1 + sqrt(1 + 8 * T)) / 2;
// 因为 sqrt 可能有浮点误差,微调 k
// 保证 k 是满足 k^2 - k <= 2T 的最大整数
while (k * k - k <= 2 * T) k++; // 可能偏小,往上找
while (k > 0 && k * k - k > 2 * T) k--; // 可能偏大,往下找
// 计算最优期望 S = (k^2 - k + 2T) / (2k)
ll num = k * k - k + 2 * T; // 分子
ll den = 2 * k; // 分母
// 约分
ll g = gcd(num, den);
num /= g;
den /= g;
// 输出最简分数
cout << num << " " << den << "\n";
}
return 0;
}
private:
typedef long long ll;
// 求最大公约数(用于约分)
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
博弈论区域赛题
2022年中国大学生程序设计竞赛H---博弈论
-
题目链接: 2022年中国大学生程序设计竞赛H
-
题目描述及核心思路:
text【题目描述】 给定一个集合 S,初始包含 n 个互不相同的自然数 a[1..n]。 定义 MEX(S) 为最小的不在 S 中的自然数。 St. Alice 和 St. Bob 轮流操作,Alice 先手。 每轮当前玩家可以选任意一个整数 x,把它插入 S 中。 经过 k 轮之后,即双方各操作 k 次,游戏结束。 若 MEX(S) 为偶数,则 Alice 胜;否则 Bob 胜。 双方都采取最优策略,问最终谁胜。 【输入】 第一行:T,表示测试用例个数。 每个测试用例: 第一行:n k 第二行:n 个整数 a[1..n](互不相同) 【输出】 每个测试用例输出一行:Alice 或 Bob。 【数据范围】 1 <= T <= 2e5 1 <= n, k <= 2e5 0 <= a[i] <= 1e6 所有测试用例的 n 之和 <= 2e5 所有测试用例的 k 之和 <= 2e5 【样例1】 输入: 5 14 5 7 13 1 6 14 2 16 17 18 19 34 36 20 23 13 5 8 10 3 13 14 15 16 17 18 19 20 36 38 14 5 14 20 12 6 0 16 8 11 9 17 13 3 5 19 14 5 15 7 13 3 1 17 16 14 0 12 4 10 22 53 14 5 7 3 4 0 14 15 16 17 18 19 20 21 22 23 输出: Bob Bob Alice Bob Alice 【思路解说】 【一、核心观察】 最终 MEX 的值等于 S 中未被插入的最小自然数。 每次插入只会"填掉"一个缺失值,不会创造新的缺失值。 所以游戏等价于: 初始有一个缺失值集合 C = N \ S, 双方轮流从 C 中选一个数插入(即从 C 中删除一个数), 每人操作 k 次,最终 min(C) 的奇偶性决定胜负。 Alice 希望最终 MEX 为偶数, Bob 希望最终 MEX 为奇数。 【二、双方目标拆解】 设初始 MEX = m。 - Alice 为了让最终 MEX 是偶数,需要把比某个偶数更小的奇数都"填掉"。 也就是说,她需要填掉缺失的奇数。 - Bob 为了让最终 MEX 是奇数,需要把比某个奇数更小的偶数都"填掉"。 也就是说,他需要填掉缺失的偶数。 双方每轮只能填一个数。 所以问题变成: 从 m 开始往上数, Alice 能填掉多少个缺失的奇数, Bob 能填掉多少个缺失的偶数。 【三、胜负判定】 从 m 开始向上扫描每个整数 x: - 若 x 不在 S 中,说明 x 是一个缺失值。 - 若 x 是奇数:Alice 需要在某一轮填掉它。 如果缺失的奇数个数达到 k+1, 说明 Alice 在 k 轮内填不完这些奇数, 最终 MEX 会落在某个奇数上,Bob 胜。 - 若 x 是偶数:Bob 需要在某一轮填掉它。 如果缺失的偶数个数达到 k+1, 说明 Bob 在 k 轮内填不完这些偶数, 最终 MEX 会落在某个偶数上,Alice 胜。 因为 Alice 先手,她在自己第 k+1 次操作前, 若已存在 k+1 个缺失奇数,就无法全部填掉 → Bob 胜。 同理,Bob 在自己第 k+1 次操作前, 若已存在 k+1 个缺失偶数,就无法全部填掉 → Alice 胜。 【四、算法流程】 1) 读入序列,存入哈希表或排序去重数组。 2) 计算初始 MEX = m: while (m 在 S 中) m++。 3) 从 x = m 开始向上扫描: odd = 0, even = 0 while (true): if x 不在 S 中: if x 是奇数: odd++ if odd == k + 1: 输出 Bob,结束 else: even++ if even == k + 1: 输出 Alice,结束 x++ 4) 输出答案。 【五、复杂度】 每个测试用例: 计算 MEX:O(n) 向上扫描:最多扫描到 m + 2k + 1, 由于 n 和 k 总和 <= 2e5, 总扫描次数 O(n + k)。 总复杂度 O(n + k),可以过。 【六、样例验证】 样例1 第一组: S = {7,13,1,6,14,2,16,17,18,19,34,36,20,23} 初始 MEX = 0 从 0 开始: x=0: 缺失,偶数,even=1 x=1: 存在 x=2: 存在 x=3: 缺失,奇数,odd=1 x=4: 缺失,偶数,even=2 x=5: 缺失,奇数,odd=2 k=5,odd 和 even 都没到 6,继续 ... 最终 odd 先到 6,所以 Bob 胜 ✓ 样例1 第三组: S = {14,20,12,6,0,16,8,11,9,17,13,3,5,19} 初始 MEX = 1(因为 0 在,1 不在) 从 1 开始: x=1: 缺失,奇数,odd=1 x=2: 缺失,偶数,even=1 x=3: 存在 x=4: 缺失,偶数,even=2 x=5: 存在 x=6: 存在 x=7: 缺失,奇数,odd=2 ... k=5,看谁先到 6 实际 even 先到 6 → Alice 胜 ✓ 【七、总结】 按 MEX 起点向上扫描 → 分别统计缺失奇数和缺失偶数的个数 → 谁先达到 k+1 谁就输 → 奇数的 k+1 触发 Bob 胜,偶数的 k+1 触发 Alice 胜 → 预处理集合 + 线性扫描 → 总复杂度 O(n + k)-
基于以上思路,代码如下:
cpp#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, k; cin >> n >> k; // 用哈希表存储初始集合 S,方便 O(1) 查询某个数是否存在 unordered_set<int> S; S.reserve(n * 2); // 预分配空间,避免 rehash 影响性能 for (int i = 0; i < n; ++i) { int a; cin >> a; S.insert(a); } // 计算初始 MEX:最小的不在 S 中的自然数 int mex = 0; while (S.find(mex) != S.end()) mex++; // odd 记录从 mex 开始,缺失的奇数个数 // even 记录从 mex 开始,缺失的偶数个数 int odd = 0, even = 0; int x = mex; // 从 mex 开始向上扫描 string ans; // 双方各操作 k 次,总共 2k 次插入 // Alice 希望最终 MEX 是偶数,Bob 希望是奇数 // 从 mex 开始往上扫,统计缺失的奇数和偶数 while (true) { if (S.find(x) == S.end()) { // x 不在 S 中,是缺失值 if (x & 1) { // x 是奇数 odd++; // Alice 有 k 次操作,最多填掉 k 个缺失的奇数 // 如果缺失的奇数达到 k+1 个,说明 Alice 填不完 // 最终 MEX 会落在某个奇数上 → Bob 胜 if (odd == k + 1) { ans = "Bob"; break; } } else { // x 是偶数 even++; // Bob 有 k 次操作,最多填掉 k 个缺失的偶数 // 如果缺失的偶数达到 k+1 个,说明 Bob 填不完 // 最终 MEX 会落在某个偶数上 → Alice 胜 if (even == k + 1) { ans = "Alice"; break; } } } x++; // 继续向上扫描 } cout << ans << '\n'; } return 0; }
-
贪心区域赛题
第45届国际大学生编程设计竞赛济南站D---贪心
- 题目链接:第45届国际大学生编程设计竞赛济南站D
- 题目描述及思路:
text
Fight against involution(ICPC 济南 2020 D / 牛客 10662 D)
【题目描述】
有 n 个学生,每个学生需要写一篇论文,第 i 个学生的论文字数 w_i 必须满足:
L_i ≤ w_i ≤ R_i
成绩规则:第 i 个学生的成绩 g_i = n - K_i,其中 K_i 是满足 w_j > w_i 的学生数量。
也就是说,成绩 = 比我字数多的人数越少,成绩越高。
每个学生都想拿到最高成绩,所以在"最优决策"下,每个学生都会写到上限 R_i。
此时,所有学生的成绩都是 n(因为没有人字数比你多?不,如果大家都写 R_i,那么成绩取决于 R_i 的分布)。
但 MianKing 发现:如果大家都写很少的字数,成绩可能不变,但可以省下时间打游戏。
现在 MianKing 想要重新决定每个学生的 w_i,满足以下条件:
1) 每个学生的成绩不能低于原来(即大家都写 R_i 时)的成绩。
2) 最小化所有学生的字数之和 Σ w_i。
求最小的 Σ w_i。
【输入】
第一行:n(1 ≤ n ≤ 10^5)
接下来 n 行:每行两个整数 L_i, R_i(1 ≤ L_i ≤ R_i ≤ 10^9)
【输出】
一个整数,表示最小的 Σ w_i。
【样例1】
输入:
3
1 10000
1 10000
1 10000
输出:
3
【样例2】
输入:
4
1 2
2 2
2 4
3 4
输出:
10
【思路解说】
【一、原始成绩的计算】
在"最优决策"下,每个学生都写 R_i。
此时,第 i 个学生的成绩 = n - (满足 R_j > R_i 的学生数)。
也就是说,成绩只取决于 R_i 的大小关系。
如果两个学生的 R_i 相同,他们的成绩相同。
如果 R_i 不同,R_i 越大的学生,成绩越低(因为比他字数多的人少?不对,R_i 越大,比他字数多的人越少,所以成绩越高)。
实际上,成绩 = n - (R_j > R_i 的人数)。
所以 R_i 越大,成绩越高。
【二、约束条件】
我们需要给每个学生选一个新的 w_i,满足:
1) L_i ≤ w_i ≤ R_i
2) 新成绩 ≥ 原成绩
原成绩 = n - (R_j > R_i 的人数)。
新成绩 = n - (w_j > w_i 的人数)。
所以条件 2 等价于:
(w_j > w_i 的人数) ≤ (R_j > R_i 的人数)
也就是说,新方案中,比 i 字数多的人数不能超过原方案中比 i 字数多的人数。
【三、贪心策略:按 R 分组,从大到小处理】
把学生按 R_i 从大到小排序。
对于 R_i 相同的学生,他们原本的成绩相同(因为 R_j > R_i 的人数相同)。
**关键观察:**
- 对于 R_i 最大的那一组,原成绩 = n(因为没有人 R_j > R_i)。
- 所以新方案中,这一组的学生成绩也必须是 n,即没有人 w_j > w_i。
- 这意味着这一组的学生必须写得**不比任何人少**,即他们的 w_i 必须 ≥ 其他所有人的 w_i。
- 为了最小化总和,这一组的学生应该取 w_i = max(L_i, 其他组的最大的 w)。
**更精确地:**
- 从 R 最大的组开始处理。
- 设当前组的 maxL = 组内所有 L_i 的最大值。
- 设下一组的 w 值为 next_w(即下一组实际取的字数)。
- 当前组的 w 必须 ≥ next_w(因为当前组成绩不能低于下一组?实际上,当前组 R 更大,成绩更高,所以当前组的 w 必须 ≥ 下一组的 w)。
- 同时,当前组的 w 必须 ≥ maxL(满足下界)。
- 所以当前组的 w = max(maxL, next_w)。
- 当前组的总贡献 = w × 组内人数。
- 更新 next_w = w,继续处理上一组。
【四、算法流程】
1) 读入 n 和所有 (L_i, R_i)。
2) 按 R_i 从大到小排序。
3) 把 R_i 相同的学生分到同一组,记录每组的 maxL 和人数。
4) 从最后一组(R 最小)往前处理:
- 初始 next_w = 0。
- 对于当前组,w = max(组内 maxL, next_w)。
- 答案 += w × 组内人数。
- next_w = w。
5) 输出答案。
【五、样例验证】
样例1:三个学生都是 (1, 10000)
按 R 分组:一组,maxL = 1,人数 3。
从后往前:next_w = 0,w = max(1, 0) = 1。
答案 = 1 × 3 = 3 ✓
样例2:四个学生 (1,2), (2,2), (2,4), (3,4)
按 R 分组:
R=4: (2,4), (3,4) -> maxL = 3, 人数 2
R=2: (1,2), (2,2) -> maxL = 2, 人数 2
从后往前:
最后一组 R=2: next_w = 0, w = max(2, 0) = 2, 答案 += 2×2 = 4, next_w = 2
上一组 R=4: w = max(3, 2) = 3, 答案 += 3×2 = 6, 总答案 = 10 ✓
【六、复杂度】
- 排序:O(n log n)
- 分组和处理:O(n)
- 总复杂度:O(n log n),可以过。
【七、注意事项】
- 按 R 从大到小排序。
- 同一 R 的学生分到一组,取组内 maxL。
- 从最后一组往前贪心,w = max(maxL, next_w)。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 学生结构体:L 是下界,R 是上界
struct Student {
int L, R;
};
int main() {
int n;
scanf("%d", &n);
vector<Student> a(n);
for (int i = 0; i < n; i++) {
scanf("%d %d", &a[i].L, &a[i].R);
}
// 按 R 从大到小排序
// 因为 R 越大,原始成绩越高,需要优先保证这些学生的成绩不降低
sort(a.begin(), a.end(), [](const Student &x, const Student &y) {
return x.R > y.R;
});
// 按 R 分组,每组记录:
// maxL:组内所有 L 的最大值(为了满足组内所有学生的下界)
// count:组内学生人数
vector<pair<int, int>> groups; // (maxL, count)
int i = 0;
while (i < n) {
int j = i;
int maxL = 0;
// 把 R 相同的所有学生分到同一组
while (j < n && a[j].R == a[i].R) {
maxL = max(maxL, a[j].L);
j++;
}
groups.push_back({maxL, j - i});
i = j;
}
// 从最后一组(R 最小)往前贪心
// 原因:R 越小的组,原始成绩越低,新方案中字数可以更少
// 但新方案中,字数必须满足:R 大的组字数 >= R 小的组字数
// (否则 R 大的组成绩会降低)
ll ans = 0;
int next_w = 0; // 下一组的实际字数,初始为 0(最后一组没有下一组)
// 从后往前处理每组
for (int k = (int)groups.size() - 1; k >= 0; k--) {
// 当前组的字数 = max(组内下界 maxL, 下一组的字数 next_w)
// 因为当前组 R 更大,成绩更高,所以字数必须 >= 下一组的字数
int w = max(groups[k].first, next_w);
// 当前组所有学生都取 w,累加贡献
ans += (ll)w * groups[k].second;
// 更新 next_w,供上一组使用
next_w = w;
}
printf("%lld\n", ans);
return 0;
}
第45届国际大学生程序设计竞赛正式赛上海站M---建树+自下而上的贪心
- 题目链接:第45届国际大学生程序设计竞赛上海站M
- 题目描述和思路如下:
text
Gitignore(2020 ICPC 上海站 M / CF Gym 102900 M)
【题目描述】
你的 git 项目中有一些文件需要被忽略(不同步)。你需要计算 `gitignore` 文件最少需要写多少行。
形式上,你的项目是一个文件夹。文件夹可以包含文件和子文件夹。没有空文件夹(即没有不包含任何文件或子文件夹的文件夹)。初始时,git 软件会同步项目中的所有文件。你可以在 `gitignore` 中指定一些文件或文件夹来排除它们。对于 `gitignore` 中的每一行,你可以指定一个文件,或者指定一个文件夹(表示忽略该文件夹下的所有文件)。你不能忽略整个项目文件夹(即不能写空行)。
给定项目中所有文件的路径,以及它们是否应该被忽略。你的任务是计算 `gitignore` 的最少行数。
【输入】
第一行:T,测试用例个数。
对于每个测试用例:
第一行:n, m(0 ≤ n, m,且 1 ≤ n + m ≤ 100),分别表示应该被忽略的文件数量,和不能被忽略的文件数量。
接下来 n 行:应该被忽略的文件路径。
接下来 m 行:不能被忽略的文件路径。
路径仅包含小写英文字母和斜杠 '/'。路径不以斜杠开头或结尾,没有连续的斜杠。文件路径是唯一的,且保证在同一个文件夹下不会有同名文件或文件夹(即不会出现 "a/b/a" 和 "a/b/a/d" 的情况)。
保证整个输入中所有路径的总长度不超过 1000。
【输出】
对于每个测试用例,输出一行一个整数,表示最少需要的 `gitignore` 行数。
【样例1】
输入:
2
3 0
data/train
data/test
model
3 1
data/train
data/test
model
data/sample
输出:
2
3
【样例解释】
样例 1:`data/` 文件夹下所有文件都要忽略,可以写 1 行 `data/`;`model` 单独写 1 行。共 2 行。
样例 2:`data/` 下有一个不能忽略的 `data/sample`,所以不能整个忽略 `data/`,只能分别写 `data/train` 和 `data/test`;`model` 单独写 1 行。共 3 行。
【思路解说】
【一、核心建模:目录树】
我们可以把所有的路径(无论是否该忽略)按 `/` 拆开,建成一棵**多叉树**(类似于 Trie)。
- 根节点代表项目根目录。
- 中间的每个节点代表一个文件夹。
- 叶子节点代表一个具体的文件。
在建树的同时,我们需要在节点上打标记:
- `hasIgn = true`:表示该节点(如果是文件)或其子树中包含**应该被忽略**的文件。
- `hasKeep = true`:表示该节点或其子树中包含**不能被忽略**的文件。
**注意:**
- 必须先处理所有需要忽略的文件并打上 `hasIgn`,再处理不能忽略的文件并打上 `hasKeep`,防止信息覆盖。
- 路径的层级关系通过 `map<string, int>` 来存子节点(应对字符串名字)。
【二、贪心策略:自底向上合并】
建完树后,我们从根节点开始 DFS 计算答案。对于当前节点 `u`:
1. **如果 `hasIgn == false`**:说明该文件夹(或文件)及其子树下没有任何需要忽略的文件,对答案的贡献为 0。
2. **如果 `hasKeep == false`**:说明该文件夹及其子树下**所有文件都是可以忽略的**。此时最优策略是**直接忽略整个文件夹**(写 1 行)。所以返回贡献 1,不需要再往下拆分了。
3. **如果 `hasIgn == true` 且 `hasKeep == true`**:说明这个文件夹里既有要忽略的,又有不能忽略的。我们**不能忽略整个文件夹**(会误伤保留的文件),只能递归处理它的所有子节点,把子节点的贡献累加起来。
**【三、根节点的特判】**
题目规定,我们不能直接忽略整个项目(即 `gitignore` 中不能写空行)。
因此,对于根节点(项目目录),我们**绝对不能返回 1 代表整个忽略**,而是必须遍历它的所有子节点,把子节点的 `dfs()` 返回值累加起来。
【四、算法流程】
1) 为每个测试用例初始化一棵空树(只有根节点 0)。
2) 读入 n 个需要忽略的文件:
- 按 '/' 拆分路径,插入树中。
- 在最后一个节点标记 `hasIgn = true`。
3) 读入 m 个不能忽略的文件:
- 按 '/' 拆分路径,插入树中。
- 在最后一个节点标记 `hasKeep = true`。
4) 自底向上汇总标记(`collect` 函数):
- 递归遍历所有子节点,如果子节点有 `hasIgn` / `hasKeep`,则父节点也要继承对应标记。
5) 计算答案:
- 初始化 `ans = 0`。
- 遍历根节点(0 号节点)的所有子节点,把 `dfs(子节点)` 的返回值累加。
6) 输出 `ans`。
【五、复杂度】
- 建树:O(总字符数)
- 汇总标记:O(节点数)
- 计算答案:O(节点数)
- 总复杂度:O(总字符数),对于题目 ≤ 1000 的数据量,瞬间通过。
- 空间复杂度:O(总字符数),即树的节点数。
【六、注意事项】
- 根节点不能直接返回 1(不能忽略整个项目)。
- 建树时只创建实际路径中的节点,不需要补全其他。
- 用 `map<string, int>` 来存子节点,比固定数组更灵活且不会浪费空间。
- 每次 `T` 循环结束后必须 `tr.clear()` 并重建根节点,防止多组测试数据互相影响。
- 路径可能是单纯的文件夹,也可能是文件夹下的文件,按 `path` 中的 `/` 分割即可。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// 树节点:用 map 存孩子(名字 -> 编号)
struct Node {
map<string, int> ch; // 子节点映射:文件夹/文件名 -> 节点编号
bool hasIgn = false; // 标记:该子树内是否存在必须忽略的文件
bool hasKeep = false; // 标记:该子树内是否存在不能忽略的文件
};
vector<Node> tr; // 全局目录树,tr[0] 为根节点
// 创建一个新节点,返回其编号
int newNode() {
tr.push_back(Node());
return (int)tr.size() - 1;
}
// 把文件路径插入树中,返回该路径最终指向的节点编号
int insertPath(const string& path) {
int cur = 0; // 从根节点 0 开始
string seg;
// 遍历字符串直到末尾(i == path.size() 时也能触发一次处理)
for (size_t i = 0; i <= path.size(); i++) {
if (i == path.size() || path[i] == '/') {
if (!seg.empty()) { // 提取出一段路径名
auto it = tr[cur].ch.find(seg);
int nxt;
if (it == tr[cur].ch.end()) {
// 如果子节点不存在,则新建
nxt = newNode();
tr[cur].ch[seg] = nxt;
} else {
// 如果已存在,直接复用
nxt = it->second;
}
cur = nxt;
seg.clear(); // 清空当前段,准备读下一段
}
} else {
seg += path[i]; // 拼接当前段的字符
}
}
return cur; // 返回叶子节点(即文件本身)的编号
}
// 自底向上汇总标记:如果子节点有 hasIgn / hasKeep,父节点也要继承
void collect(int u) {
for (auto& kv : tr[u].ch) {
collect(kv.second); // 递归处理子节点
// 子节点有的标记,父节点也要有
tr[u].hasIgn = tr[u].hasIgn || tr[kv.second].hasIgn;
tr[u].hasKeep = tr[u].hasKeep || tr[kv.second].hasKeep;
}
}
// 核心 DFS:计算忽略 u 子树下所有该忽略文件的最少行数
int dfs(int u) {
if (!tr[u].hasIgn) return 0; // 子树里没有任何要忽略的,不需要操作
if (!tr[u].hasKeep) return 1; // 子树里全是该忽略的,直接忽略整个目录(1 行)
// 子树里既有该忽略的,又有必须保留的
// 不能整体忽略当前目录,只能拆开分别处理各个子节点
int sum = 0;
for (auto& kv : tr[u].ch) {
sum += dfs(kv.second); // 累加每个子节点需要的行数
}
return sum;
}
int main() {
int T;
if (scanf("%d", &T) != 1) return 0; // 读入测试用例数
while (T--) {
int n, m;
scanf("%d %d", &n, &m); // n: 要忽略的文件数,m: 不能忽略的文件数
tr.clear(); // 清空上一组数据的树
newNode(); // 创建根节点 0
char buf[1005]; // 读入路径用的缓冲区
// ---------- 先处理要忽略的文件 ----------
vector<int> ignNodes; // 其实不需要保存,为了和原代码一致保留
for (int i = 0; i < n; i++) {
scanf("%s", buf);
int id = insertPath(string(buf));
tr[id].hasIgn = true; // 在该文件节点标记:这是要忽略的文件
ignNodes.push_back(id);
}
// ---------- 再处理不能忽略的文件 ----------
for (int i = 0; i < m; i++) {
scanf("%s", buf);
int id = insertPath(string(buf));
tr[id].hasKeep = true; // 在该文件节点标记:这是不能忽略的文件
}
// ---------- 自底向上汇总标记 ----------
collect(0); // 从根节点开始,把子树标记传递到父节点
// ---------- 计算答案 ----------
// 根节点不能整体忽略,必须拆开,逐个处理根的子节点
int ans = 0;
for (auto& kv : tr[0].ch) {
ans += dfs(kv.second); // 累加每个顶级目录/文件所需的行数
}
printf("%d\n", ans); // 输出当前测试用例的答案
}
return 0;
}