区域赛分类题解

文章目录

区域赛分类题解

图论区域赛题题解

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---网络流

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---网络流

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

  • ​题目描述及思路:

    text 复制代码
    Equal 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

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(第一题)---背包问题

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

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+容斥定理

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+最短路

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前缀函数

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前缀函数+二分

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失配树

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

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算法

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+线段树

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---可持续化线段树

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---逆序对问题

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---带权并查集

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---基环树

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---同余原理

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---组合数学

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---容斥原理+组合计数

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---质数筛

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运算

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 运算。

【三、算法流程】

  1. 读入 n 和数组 a。
  2. 排序去重,得到唯一的元素集合。
  3. 如果去重后只剩一个元素(所有数相同),输出 infinite。
  4. 取 a₁ = a0,计算 D = gcd(a1-a0, a2-a0, ..., an-1-a0)。
  5. 如果 D == 0,输出 infinite。
  6. 用迭代 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
  7. 输出 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);
      vector a(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---数学期望

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---贪心

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---建树+自下而上的贪心

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;
}
相关推荐
m0_674571971 小时前
力扣 hot 100 python
python·算法·leetcode
青少儿编程课堂2 小时前
蓝桥杯+全国青少年信息素养大赛+电子学会PythonC/C++考级:最长公共子序列LCS解析
c++·python·算法·bfs·信息学竞赛
倚楼听风语3 小时前
Kotlin 协程与 suspend 原理详解
java·算法·kotlin
程序员清风3 小时前
双指针与滑动窗口:高效处理连续区间
开发语言·算法
代钦塔拉3 小时前
【C++/Qt 进阶】设计模式系列(2):工厂模式——从游戏角色到披萨店
c++·算法·设计模式
思茂信息3 小时前
电磁仿真学涉及哪些算法?
人工智能·算法·数学建模·matlab·emc·电磁仿真·仿真辐射
糖果豆豆4 小时前
让 AI 自查三遍,它只会把错的说得更像对的——检查的人和被检查的,是同一个
算法·架构
成都蜀味缘餐饮技术培训学校4 小时前
油炸串串技术培训实战指南,核心食材选购与预处理标准
数据仓库·经验分享·笔记·课程设计·微信公众平台
摇滚侠4 小时前
《大模型应用开发实践:基于 Spring AI+DeepSeek 实现》第 11 章 大模型技术概览 阅读笔记 1
人工智能·笔记·spring