区域赛算法模版第四章---数据结构

文章目录

    • 四、区域赛算法模版第四章---数据结构
      • [4.1 线段树(懒标记)](#4.1 线段树(懒标记))
        • [4.1.1 可持续化线段树(主席树)](#4.1.1 可持续化线段树(主席树))
        • [4.1.2 区域赛题](#4.1.2 区域赛题)
      • [4.2 笛卡尔树(Cartesian Tree)](#4.2 笛卡尔树(Cartesian Tree))
      • [4.3 替罪羊树(Scapegoat Tree)](#4.3 替罪羊树(Scapegoat Tree))
      • [4.4 树状数组(Fenwick Tree)](#4.4 树状数组(Fenwick Tree))
        • [4.4.1 基础模板(单点修改 + 区间查询)](#4.4.1 基础模板(单点修改 + 区间查询))
        • [4.4.2 差分应用:区间修改 + 单点查询](#4.4.2 差分应用:区间修改 + 单点查询)
        • [4.4.3 双树状数组:区间修改 + 区间查询](#4.4.3 双树状数组:区间修改 + 区间查询)
        • [4.4.4 二维树状数组(单点修改 + 子矩阵查询)](#4.4.4 二维树状数组(单点修改 + 子矩阵查询))
        • [4.4.5 离散化技巧](#4.4.5 离散化技巧)
        • [4.4.6 树状数组 vs 线段树](#4.4.6 树状数组 vs 线段树)
        • [4.4.7 注意事项](#4.4.7 注意事项)
        • [4.4.8 区域赛题](#4.4.8 区域赛题)
      • [4.5 并查集(DSU)](#4.5 并查集(DSU))
        • 4.5.1普通并查集
        • [4.5.2 带权并查集](#4.5.2 带权并查集)
        • [4.5.3 可持久化并查集](#4.5.3 可持久化并查集)
        • [4.5.4 区域赛题](#4.5.4 区域赛题)
      • [4.6 单调栈和单调队列](#4.6 单调栈和单调队列)
        • 4.6.1单调栈
        • [4.6.2 单调队列](#4.6.2 单调队列)
        • [4.6.3 区域赛题](#4.6.3 区域赛题)
      • [4.7 基环树](#4.7 基环树)
        • [4.7.1 基本原理](#4.7.1 基本原理)
        • [4.7.2 找环(拓扑排序法)](#4.7.2 找环(拓扑排序法))
        • [4.7.3 找环(DFS 法)](#4.7.3 找环(DFS 法))
        • [4.7.4 断环成树 + 树形 DP](#4.7.4 断环成树 + 树形 DP)
        • [4.7.5 环上 DP(复制成链 + 单调队列)](#4.7.5 环上 DP(复制成链 + 单调队列))
        • [4.7.6 基环树常见题型](#4.7.6 基环树常见题型)
        • [4.7.7 注意事项](#4.7.7 注意事项)
        • [4.7.8 时间复杂度总结](#4.7.8 时间复杂度总结)
        • [4.7.9 区域赛题](#4.7.9 区域赛题)
      • [4.9 莫队](#4.9 莫队)
        • [4.9.1 基本原理](#4.9.1 基本原理)
        • [4.9.2 普通莫队(区间不同数个数)](#4.9.2 普通莫队(区间不同数个数))
        • [4.9.3 只增回滚莫队(区间最大重要度)](#4.9.3 只增回滚莫队(区间最大重要度))
        • [4.9.4 莫队二次离线(区间 k1 二元组)](#4.9.4 莫队二次离线(区间 k1 二元组))
        • [4.9.5 莫队的常见变体](#4.9.5 莫队的常见变体)
        • [4.9.6 时间复杂度总结](#4.9.6 时间复杂度总结)
        • [4.9.7 注意事项](#4.9.7 注意事项)
      • [4.x ST表(Sparse Table,稀疏表)](#4.x ST表(Sparse Table,稀疏表))
        • [4.x.1 基本原理](#4.x.1 基本原理)
        • [4.x.2 区间最大值 / 最小值(洛谷 P2880)](#4.x.2 区间最大值 / 最小值(洛谷 P2880))
        • [4.x.3 区间 GCD(洛谷 P1890)](#4.x.3 区间 GCD(洛谷 P1890))
        • [4.x.4 区间按位与 / 按位或](#4.x.4 区间按位与 / 按位或)
        • [4.x.5 ST表 vs 线段树](#4.x.5 ST表 vs 线段树)
        • [4.x.6 常见应用总结](#4.x.6 常见应用总结)
        • [4.x.7 复杂度总结](#4.x.7 复杂度总结)
        • [4.x.8 注意事项](#4.x.8 注意事项)
      • 技巧补充---数链剖分

四、区域赛算法模版第四章---数据结构

4.1 线段树(懒标记)

适用场景: 区间修改、区间查询(和、最值等)

cpp 复制代码
class Solver {
public:
    struct SegTree {
        long long sum[N << 2], lazy[N << 2];

        void push_up(int p) {
            sum[p] = sum[p<<1] + sum[p<<1|1];
        }

        void push_down(int p, int l, int r) {
            if (lazy[p]) {
                int mid = (l + r) >> 1;
                sum[p<<1] += lazy[p] * (mid - l + 1);
                sum[p<<1|1] += lazy[p] * (r - mid);
                lazy[p<<1] += lazy[p];
                lazy[p<<1|1] += lazy[p];
                lazy[p] = 0;
            }
        }

        void build(int p, int l, int r, int a[]) {
            if (l == r) { sum[p] = a[l]; return; }
            int mid = (l + r) >> 1;
            build(p<<1, l, mid, a);
            build(p<<1|1, mid+1, r, a);
            push_up(p);
        }

        void update(int p, int l, int r, int ul, int ur, int val) {
            if (ul <= l && r <= ur) {
                sum[p] += val * (r - l + 1);
                lazy[p] += val;
                return;
            }
            push_down(p, l, r);
            int mid = (l + r) >> 1;
            if (ul <= mid) update(p<<1, l, mid, ul, ur, val);
            if (ur > mid) update(p<<1|1, mid+1, r, ul, ur, val);
            push_up(p);
        }

        long long query(int p, int l, int r, int ql, int qr) {
            if (ql <= l && r <= qr) return sum[p];
            push_down(p, l, r);
            int mid = (l + r) >> 1;
            long long res = 0;
            if (ql <= mid) res += query(p<<1, l, mid, ql, qr);
            if (qr > mid) res += query(p<<1|1, mid+1, r, ql, qr);
            return res;
        }
    };
};

思路要点:

  • 每个节点代表一个区间,二叉树结构

  • 懒标记:延迟下传修改,整块更新时打标记

  • 查询/更新到子节点前必须 push_down

  • 时间复杂度 O(log n) 单次操作

4.1.1 可持续化线段树(主席树)

适用场景: 静态区间第 k 小/大、区间不同数个数、可持久化权值线段树等

cpp 复制代码
class Solver {
public:
    struct PersistentSegTree {
        // root[i] 表示第 i 个版本的根节点编号
        // lc[p], rc[p] 分别表示节点 p 的左右儿子编号
        // sum[p] 表示节点 p 对应区间内的元素个数(权值线段树中为出现次数)
        int root[N], lc[N << 5], rc[N << 5], sum[N << 5];
        int tot;  // 节点计数器

        // 单点修改:在版本 pre 的基础上,将位置 pos 的值增加 val,返回新版本根节点编号
        // 调用方式:root[i] = update(root[i - 1], 1, m, pos, 1);
        int update(int pre, int l, int r, int pos, int val) {
            int p = ++tot;                 // 复制出新节点
            lc[p] = lc[pre];
            rc[p] = rc[pre];
            sum[p] = sum[pre] + val;       // 继承旧信息并加上增量

            if (l == r) return p;          // 叶子节点直接返回

            int mid = (l + r) >> 1;
            if (pos <= mid) lc[p] = update(lc[pre], l, mid, pos, val);
            else           rc[p] = update(rc[pre], mid + 1, r, pos, val);
            return p;
        }

        // 区间查询:查询版本 u 到版本 v 之间,权值区间 [ql, qr] 内的元素个数
        // 常用于求静态区间第 k 小:query(root[l - 1], root[r], 1, m, ql, qr)
        int query(int u, int v, int l, int r, int ql, int qr) {
            if (ql <= l && r <= qr) return sum[v] - sum[u];

            int mid = (l + r) >> 1;
            int res = 0;
            if (ql <= mid) res += query(lc[u], lc[v], l, mid, ql, qr);
            if (qr > mid)  res += query(rc[u], rc[v], mid + 1, r, ql, qr);
            return res;
        }

        // 静态区间第 k 小:查询版本 u 到版本 v 之间的第 k 小值
        // 调用方式:kth(root[l - 1], root[r], 1, m, k)
        int kth(int u, int v, int l, int r, int k) {
            if (l == r) return l;

            int mid = (l + r) >> 1;
            int cnt = sum[lc[v]] - sum[lc[u]];  // 左子树中的元素个数
            if (k <= cnt) return kth(lc[u], lc[v], l, mid, k);
            else          return kth(rc[u], rc[v], mid + 1, r, k - cnt);
        }

        // 初始化:建立空树(所有节点 sum 均为 0)
        // 调用方式:build(root[0], 1, m);
        void build(int &p, int l, int r) {
            p = ++tot;
            sum[p] = 0;
            if (l == r) return;

            int mid = (l + r) >> 1;
            build(lc[p], l, mid);
            build(rc[p], mid + 1, r);
        }
    };
};

思路要点:

  • 可持久化的核心思想:每次修改只复制发生变化的节点,未变化的节点与旧版本共享
  • 单点修改一次会新建 O(log n) 个节点,因此空间复杂度为 O(n log n)
  • root[i] 表示第 i 个版本的根节点,版本之间通过根节点区分
  • 静态区间第 k 小:对原数组离散化后,按位置依次插入权值线段树,得到 n 个版本;查询 [l, r] 时利用前缀和思想,用 root[r] - root[l-1] 在权值线段树上二分
  • 查询/修改均递归到子节点,不需要懒标记(主席树通常处理单点修改)
  • 时间复杂度:单次修改/查询 O(log n),空间复杂度 O(n log n)

补充说明:

  1. 数组大小 :N << 5 是常见开法,因为每次修改新建约 log n 个节点,总共约 n log n 个节点。若数据范围较大,可改为 N * 20 或 N * 25 等。

  2. 静态区间第 k 小完整调用示例:

cpp 复制代码
class Solver {
public:
    void run() {
        // 离散化
        vector<int> xs(a + 1, a + n + 1);
        sort(xs.begin(), xs.end());
        xs.erase(unique(xs.begin(), xs.end()), xs.end());
        int m = xs.size();
        PersistentSegTree pst;
        pst.tot = 0;
        pst.build(pst.root[0], 1, m);
        for (int i = 1; i <= n; i++) {
            int pos = lower_bound(xs.begin(), xs.end(), a[i]) - xs.begin() + 1;
            pst.root[i] = pst.update(pst.root[i - 1], 1, m, pos, 1);
        }
        // 查询区间 [l, r] 第 k 小
        int pos = pst.kth(pst.root[l - 1], pst.root[r], 1, m, k);
        int ans = xs[pos - 1];
    }
};
  1. 与普通线段树的区别 :
    • 普通线段树节点编号固定为 p << 1 和 p << 1 | 1,主席树动态开点,用 lc[p]、rc[p] 记录儿子
    • 普通线段树支持区间修改 + 懒标记,主席树通常只支持单点修改(区间修改需要标记永久化等技巧)
    • 主席树每个版本共享大量节点,空间换时间
4.1.2 区域赛题
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;
}
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;
}

4.2 笛卡尔树(Cartesian Tree)

适用场景: 由序列构建的 BST + 堆结构,常用于 RMQ 转 LCA、单调栈建树、区间最值分治等

cpp 复制代码
class Cartesian {
public:
    struct CartesianTree {
        // a[1..n] 为原序列(下标从 1 开始)
        // lc[i], rc[i] 分别为节点 i 的左右儿子
        // stk[] 为单调栈,维护右链
        int lc[N], rc[N], stk[N];
        int top;      // 栈顶指针
        int root;     // 笛卡尔树的根

        // 以小根堆为例(父节点值 < 子节点值)
        // 若需大根堆,把 while 中的 a[stk[top]] > a[i] 改为 < 即可
        void build(int n, int a[]) {
            top = 0;
            root = 0;
            for (int i = 1; i <= n; i++) {
                lc[i] = rc[i] = 0;

                // 弹出栈中所有比 a[i] 大的元素,最后一个弹出的成为 i 的左儿子
                int last = 0;
                while (top && a[stk[top]] > a[i]) {
                    last = stk[top--];
                }
                lc[i] = last;

                // 栈顶元素成为 i 的父亲,i 作为其右儿子
                if (top) {
                    rc[stk[top]] = i;
                } else {
                    root = i;   // 栈空则 i 为当前根
                }

                stk[++top] = i;
            }
        }
    };
};

思路要点:

  • 笛卡尔树同时满足两个性质:
    • BST 性质:中序遍历结果就是原序列顺序
    • 堆性质:父节点权值小于(或大于)子节点权值
  • 用单调栈 在线性时间内建树,维护当前树的右链
  • 每插入一个新节点 i:
    • 弹出右链上所有不满足堆性质的节点,最后一个弹出的成为 i 的左儿子
    • 栈顶节点成为 i 的父亲,i 作为其右儿子
  • 时间复杂度 O(n),空间复杂度 O(n)
  • 若序列中存在相同元素,堆性质通常取 < 或 <= 需与题意一致,否则结构可能不唯一

典型应用:

  1. RMQ 转 LCA :对序列建小根堆笛卡尔树,区间 [l, r] 的最小值所在位置就是 lca(l, r)
  2. 直方图最大矩形:对高度数组建笛卡尔树,枚举每个节点作为最矮柱子向两边扩展
  3. 区间最值分治:笛卡尔树天然把序列按最小值分治成左右两部分

4.3 替罪羊树(Scapegoat Tree)

适用场景: 需要动态插入/删除/查询第 k 小,且不希望旋转、希望实现简单的平衡树

cpp 复制代码
class Scapegoat {
public:
    struct ScapegoatTree {
        // lc[p], rc[p] 左右儿子
        // val[p] 节点权值
        // sz[p] 子树实际节点数
        // cnt[p] 当前节点重复值个数
        // sum[p] 子树节点总数(含重复)
        // exist[p] 节点是否存在(用于惰性删除)
        int lc[N], rc[N], val[N], sz[N], cnt[N], sum[N];
        bool exist[N];
        int tot;          // 节点计数器
        int root;         // 根节点
        double alpha;     // 平衡因子,一般取 0.75 或 0.7

        ScapegoatTree() : tot(0), root(0), alpha(0.75) {}

        // 判断节点 p 是否失衡:某棵子树大小超过 alpha * 当前子树大小
        bool imbalance(int p) {
            return (double)max(sz[lc[p]], sz[rc[p]]) > alpha * sz[p];
        }

        // 更新节点信息
        void push_up(int p) {
            sz[p] = sz[lc[p]] + sz[rc[p]] + (exist[p] ? 1 : 0);
            sum[p] = sum[lc[p]] + sum[rc[p]] + cnt[p];
        }

        // 中序遍历收集子树所有节点到 vector 中
        void collect(int p, vector<int> &v) {
            if (!p) return;
            collect(lc[p], v);
            if (exist[p]) v.push_back(p);
            collect(rc[p], v);
        }

        // 以 v 中的节点重建平衡子树,返回新的根
        int rebuild(int l, int r, vector<int> &v) {
            if (l > r) return 0;
            int mid = (l + r) >> 1;
            int p = v[mid];
            lc[p] = rebuild(l, mid - 1, v);
            rc[p] = rebuild(mid + 1, r, v);
            push_up(p);
            return p;
        }

        // 重建以 p 为根的子树
        void rebuildTree(int &p) {
            vector<int> v;
            collect(p, v);
            p = rebuild(0, (int)v.size() - 1, v);
        }

        // 插入值为 x 的节点
        void insert(int &p, int x) {
            if (!p) {
                p = ++tot;
                lc[p] = rc[p] = 0;
                val[p] = x;
                sz[p] = sum[p] = cnt[p] = 1;
                exist[p] = true;
                return;
            }

            if (x == val[p]) {
                cnt[p]++;
                sz[p]++;
                sum[p]++;
                return;
            }

            if (x < val[p]) insert(lc[p], x);
            else            insert(rc[p], x);
            push_up(p);

            // 插入后若失衡,重建子树
            if (imbalance(p)) rebuildTree(p);
        }

        // 删除值为 x 的节点(惰性删除,仅减少 cnt)
        void remove(int p, int x) {
            if (!p) return;

            if (x == val[p]) {
                if (cnt[p] > 0) {
                    cnt[p]--;
                    sz[p]--;
                    sum[p]--;
                    if (cnt[p] == 0) exist[p] = false;
                }
                return;
            }

            if (x < val[p]) remove(lc[p], x);
            else            remove(rc[p], x);
            push_up(p);
        }

        // 查询值 x 的排名(小于 x 的数的个数 + 1)
        int rank(int p, int x) {
            if (!p) return 1;

            if (x <= val[p]) return rank(lc[p], x);
            else return sum[lc[p]] + cnt[p] + rank(rc[p], x);
        }

        // 查询第 k 小(k 从 1 开始)
        int kth(int p, int k) {
            if (!p) return -1;

            int leftSum = sum[lc[p]];
            if (k <= leftSum) return kth(lc[p], k);
            else if (k <= leftSum + cnt[p]) return val[p];
            else return kth(rc[p], k - leftSum - cnt[p]);
        }

        // 查询前驱(小于 x 的最大值)
        int pre(int p, int x) {
            if (!p) return -INF;
            if (val[p] < x) return max(val[p], pre(rc[p], x));
            else return pre(lc[p], x);
        }

        // 查询后继(大于 x 的最小值)
        int nxt(int p, int x) {
            if (!p) return INF;
            if (val[p] > x) return min(val[p], nxt(lc[p], x));
            else return nxt(rc[p], x);
        }

        // 对外接口
        void insert(int x) { insert(root, x); }
        void remove(int x) { remove(root, x); }
        int rank(int x)    { return rank(root, x); }
        int kth(int k)     { return kth(root, k); }
        int pre(int x)     { return pre(root, x); }
        int nxt(int x)     { return nxt(root, x); }
    };
};

思路要点:

  • 替罪羊树是一种不旋转 的平衡树,靠重构维持平衡
  • 核心思想:插入/删除后,若某节点子树失衡,就找到最高的"替罪羊"节点,将其子树拍平成有序数组 ,再二分重建为完全平衡的 BST
  • 平衡因子 alpha 一般取 0.7 ~ 0.8:
    • alpha 越小越平衡,但重构越频繁
    • alpha 越大重构越少,但树可能变高
  • 删除采用惰性删除 :只减少 cnt,不真正删除节点;当 cnt == 0 时标记 exist = false
  • 重构时只收集 exist = true 的节点,从而顺带清理惰性删除的节点
  • 均摊时间复杂度:单次插入/删除/查询 O(log n),重构均摊 O(log n)
  • 空间复杂度 O(n)

与普通平衡树对比:

特性 替罪羊树 Treap Splay
是否旋转 否 是 是
平衡方式 重构子树 随机优先级 伸展操作
实现难度 中等 简单 较难
是否支持可持久化 较难 较易 难
常数 较小 小 较大

4.4 树状数组(Fenwick Tree)

适用场景: 单点修改 + 区间查询、区间修改 + 单点查询、区间修改 + 区间查询、二维矩阵操作、离散化后维护值域、逆序对等

4.4.1 基础模板(单点修改 + 区间查询)
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 500005;

class Fenwick {
public:
    int n;
    ll tree[MAXN];

    void init(int size) {
        n = size;
        memset(tree, 0, sizeof(tree));
    }

    // 单点修改:a[i] += x
    void add(int i, ll x) {
        for (; i <= n; i += i & -i)
            tree[i] += x;
    }

    // 前缀和:a[1] + ... + a[i]
    ll query(int i) {
        ll res = 0;
        for (; i > 0; i -= i & -i)
            res += tree[i];
        return res;
    }

    // 区间和:a[l] + ... + a[r]
    ll range_query(int l, int r) {
        return query(r) - query(l - 1);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    Fenwick bit;
    bit.init(n);

    for (int i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        bit.add(i, x);
    }

    while (m--) {
        int op, l, r;
        cin >> op >> l >> r;
        if (op == 1) {
            bit.add(l, r);
        } else {
            cout << bit.range_query(l, r) << '\n';
        }
    }
    return 0;
}

思路要点:

  • lowbit = i & -i,取最低位的 1
  • 加操作:向上跳,累加
  • 查询操作:向下跳,求和
  • 空间小、常数小,功能弱于线段树
  • 时间复杂度 O(log n)
4.4.2 差分应用:区间修改 + 单点查询
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 500005;

class Fenwick {
public:
    int n;
    ll tree[MAXN];

    void init(int size) {
        n = size;
        memset(tree, 0, sizeof(tree));
    }

    void add(int i, ll x) {
        for (; i <= n; i += i & -i)
            tree[i] += x;
    }

    ll query(int i) {
        ll res = 0;
        for (; i > 0; i -= i & -i)
            res += tree[i];
        return res;
    }

    // 区间修改:[l, r] 加上 v
    void range_add(int l, int r, ll v) {
        add(l, v);
        add(r + 1, -v);
    }

    // 单点查询:a[x]
    ll point_query(int x) {
        return query(x);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    Fenwick bit;
    bit.init(n);

    ll pre = 0;
    for (int i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        bit.add(i, x - pre);
        pre = x;
    }

    while (m--) {
        int op;
        cin >> op;
        if (op == 1) {
            int l, r;
            ll v;
            cin >> l >> r >> v;
            bit.range_add(l, r, v);
        } else {
            int x;
            cin >> x;
            cout << bit.point_query(x) << '\n';
        }
    }
    return 0;
}
4.4.3 双树状数组:区间修改 + 区间查询
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 500005;

class Fenwick2 {
public:
    int n;
    ll Tree1[MAXN], Tree2[MAXN];

    void init(int size) {
        n = size;
        memset(Tree1, 0, sizeof(Tree1));
        memset(Tree2, 0, sizeof(Tree2));
    }

    // 内部单点更新:差分数组 x 位置增加 v
    void update(int x, ll v) {
        for (int i = x; i <= n; i += i & -i) {
            Tree1[i] += v;
            Tree2[i] += v * (x - 1);
        }
    }

    // 前缀和:a[1] + ... + a[x]
    ll query(int x) {
        ll s1 = 0, s2 = 0;
        for (int i = x; i > 0; i -= i & -i) {
            s1 += Tree1[i];
            s2 += Tree2[i];
        }
        return s1 * x - s2;
    }

    // 区间修改:[l, r] 加上 v
    void range_add(int l, int r, ll v) {
        update(l, v);
        update(r + 1, -v);
    }

    // 区间查询:[l, r] 的和
    ll range_query(int l, int r) {
        return query(r) - query(l - 1);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    Fenwick2 bit;
    bit.init(n);

    for (int i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        bit.range_add(i, i, x);
    }

    while (m--) {
        int op, l, r;
        cin >> op >> l >> r;
        if (op == 1) {
            ll v;
            cin >> v;
            bit.range_add(l, r, v);
        } else {
            cout << bit.range_query(l, r) << '\n';
        }
    }
    return 0;
}

公式推导:

S ( x ) = ∑ i = 1 x a i = x ⋅ ∑ i = 1 x d i − ∑ i = 1 x ( i − 1 ) d i S(x) = \sum_{i=1}^{x} a_i = x \cdot \sum_{i=1}^{x} d_i - \sum_{i=1}^{x} (i-1) d_i S(x)=i=1∑xai=x⋅i=1∑xdi−i=1∑x(i−1)di

其中 d[i] = a[i] - a[i-1],Tree1 维护 d[i],Tree2 维护 (i-1)*d[i]。

4.4.4 二维树状数组(单点修改 + 子矩阵查询)
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 2050;

class Fenwick2D {
public:
    int n, m;
    ll tree[MAXN][MAXN];

    void init(int n_, int m_) {
        n = n_;
        m = m_;
        memset(tree, 0, sizeof(tree));
    }

    // 单点修改:(x, y) 增加 v
    void update(int x, int y, ll v) {
        for (int i = x; i <= n; i += i & -i)
            for (int j = y; j <= m; j += j & -j)
                tree[i][j] += v;
    }

    // 二维前缀和:(1,1) 到 (x,y)
    ll query(int x, int y) {
        ll res = 0;
        for (int i = x; i > 0; i -= i & -i)
            for (int j = y; j > 0; j -= j & -j)
                res += tree[i][j];
        return res;
    }

    // 子矩阵查询:(x1,y1) 到 (x2,y2)
    ll range_query(int x1, int y1, int x2, int y2) {
        return query(x2, y2) - query(x1 - 1, y2)
             - query(x2, y1 - 1) + query(x1 - 1, y1 - 1);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, q;
    cin >> n >> m >> q;

    Fenwick2D bit;
    bit.init(n, m);

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            ll x;
            cin >> x;
            bit.update(i, j, x);
        }
    }

    while (q--) {
        int op;
        cin >> op;
        if (op == 1) {
            int x, y;
            ll v;
            cin >> x >> y >> v;
            bit.update(x, y, v);
        } else {
            int x1, y1, x2, y2;
            cin >> x1 >> y1 >> x2 >> y2;
            cout << bit.range_query(x1, y1, x2, y2) << '\n';
        }
    }
    return 0;
}

二维区间修改 + 区间查询 需要四棵树,分别维护 d[i][j]、i*d、j*d、i*j*d。公式:

S ( x , y ) = ( x + 1 ) ( y + 1 ) T 1 − ( y + 1 ) T 2 − ( x + 1 ) T 3 + T 4 S(x,y) = (x+1)(y+1) T_1 - (y+1) T_2 - (x+1) T_3 + T_4 S(x,y)=(x+1)(y+1)T1−(y+1)T2−(x+1)T3+T4

竞赛中按需实现,代码较长,这里不展开。

4.4.5 离散化技巧
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 500005;

class Fenwick {
public:
    int n;
    ll tree[MAXN];

    void init(int size) {
        n = size;
        memset(tree, 0, sizeof(tree));
    }

    void add(int i, ll x) {
        for (; i <= n; i += i & -i)
            tree[i] += x;
    }

    ll query(int i) {
        ll res = 0;
        for (; i > 0; i -= i & -i)
            res += tree[i];
        return res;
    }

    ll range_query(int l, int r) {
        return query(r) - query(l - 1);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<int> a(n + 1);
    vector<int> xs;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        xs.push_back(a[i]);
    }

    // 离散化
    sort(xs.begin(), xs.end());
    xs.erase(unique(xs.begin(), xs.end()), xs.end());
    int m = xs.size();

    Fenwick bit;
    bit.init(m);

    for (int i = 1; i <= n; i++) {
        int id = lower_bound(xs.begin(), xs.end(), a[i]) - xs.begin() + 1;
        bit.add(id, 1);
    }

    return 0;
}

应用场景:

  • 权值树状数组求第 k 小
  • 逆序对计数
  • 二维偏序
  • 动态维护排名

注意事项:

  • 离散化只改变下标,不改变相对大小
  • 需要保证所有查询中出现的值都提前加入 xs
  • 多组测试要清空 xs 和树状数组
4.4.6 树状数组 vs 线段树
特性 树状数组 线段树
单点修改 / 前缀查询 O(log n) O(log n)
区间修改 / 区间查询 需双树状数组 懒标记
不可差分信息(最值、GCD) 难实现 易实现
代码量 小 大
常数 小 较大
空间 O(n) O(4n)

选择建议:

  • 可差分信息(和、异或、模意义下的计数)优先用树状数组
  • 不可差分信息(最值、GCD)用线段树
  • 需要复杂区间操作时用线段树
4.4.7 注意事项
  • 树状数组下标从 1 开始,0 位置不用
  • lowbit(0) = 0,add(0, v) 会死循环
  • 双树状数组的 Tree2 维护 (i-1)*d[i]
  • 二维树状数组空间 O(n×m),注意内存
  • 离散化时所有查询值要提前加入
4.4.8 区域赛题
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;
}

4.5 并查集(DSU)

4.5.1普通并查集

适用场景: 连通性、合并集合、Kruskal

cpp 复制代码
class Solver {
public:
    struct DSU {
        int fa[N], sz[N];

        void init(int n) {
            for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1;
        }

        int find(int x) {
            return fa[x] == x ? x : fa[x] = find(fa[x]);  // 路径压缩
        }

        bool unite(int x, int y) {
            x = find(x), y = find(y);
            if (x == y) return false;
            if (sz[x] < sz[y]) swap(x, y);  // 按秩合并
            fa[y] = x;
            sz[x] += sz[y];
            return true;
        }
        bool same(int x, int y) {
            return find(x) == find(y);
        }
    };
};

思路要点:

  • 路径压缩:查询时顺便把父节点指向根

  • 按秩合并:小集合挂到大集合上

  • 近似常数时间复杂度

  • 可扩展:带权并查集、可撤销并查集

4.5.2 带权并查集

适用场景: 维护元素间的相对关系(距离、奇偶性、模意义下的差值等)

cpp 复制代码
class Solver {
public:
    struct WeightedDSU {
        int fa[N], sz[N];
        long long dis[N];  // dis[x] 表示 x 到父节点 fa[x] 的权值和

        void init(int n) {
            for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1, dis[i] = 0;
        }

        // 返回根节点,同时路径压缩并更新 dis[x] 为 x 到根的权值和
        int find(int x) {
            if (fa[x] == x) return x;
            int rt = find(fa[x]);
            dis[x] += dis[fa[x]];  // 累加父节点到根的权值
            return fa[x] = rt;
        }

        // 合并:已知 x 与 y 之间的权值差为 w,即 val[x] - val[y] = w
        // 若已同集合则返回 false,否则合并返回 true
        bool unite(int x, int y, long long w) {
            int fx = find(x), fy = find(y);
            if (fx == fy) return false;
            if (sz[fx] < sz[fy]) {
                swap(fx, fy);
                swap(x, y);
                w = -w;  // 交换后权值方向取反
            }
            // 把 fy 挂到 fx 下,需满足 dis[y] + dis[fy] - dis[x] = w
            fa[fy] = fx;
            dis[fy] = dis[x] - dis[y] + w;
            sz[fx] += sz[fy];
            return true;
        }

        // 查询 x 与 y 的权值差,即 val[x] - val[y];若不在同一集合返回 false
        bool query(int x, int y, long long &w) {
            int fx = find(x), fy = find(y);
            if (fx != fy) return false;
            w = dis[x] - dis[y];
            return true;
        }

        bool same(int x, int y) {
            return find(x) == find(y);
        }
    };
};

思路要点:

  • dis[x] 维护的是 x 到其父节点的权值差,路径压缩时顺带累加到根的权值
  • 合并时根据已知关系 val[x] - val[y] = w 推导新根之间的偏移量 dis[fy] = dis[x] - dis[y] + w
  • 注意方向:交换 x, y 时权值 w 要取反
  • 路径压缩 + 按秩合并后近似常数时间复杂度
  • 经典应用:食物链(模 3 意义下)、奇偶游戏、带距离的连通性问题
4.5.3 可持久化并查集

适用场景: 需要查询历史版本连通性、可撤销合并、强制在线回溯

cpp 复制代码
class Solver {
public:
    struct PersistentDSU {
        // 用可持久化数组维护 fa 和 sz,下标为 1..n
        // rootFa[ver] 表示第 ver 个版本的 fa 数组的根
        // rootSz[ver] 表示第 ver 个版本的 sz 数组的根
        int rootFa[N], rootSz[N];
        int lc[N << 5], rc[N << 5], faVal[N << 5], szVal[N << 5];
        int tot;

        // 可持久化数组:在版本 pre 的基础上,把位置 pos 的值改为 val
        int update(int pre, int l, int r, int pos, int val, int *valArr) {
            int p = ++tot;
            lc[p] = lc[pre];
            rc[p] = rc[pre];
            if (l == r) {
                valArr[p] = val;
                return p;
            }
            int mid = (l + r) >> 1;
            if (pos <= mid) lc[p] = update(lc[pre], l, mid, pos, val, valArr);
            else            rc[p] = update(rc[pre], mid + 1, r, pos, val, valArr);
            return p;
        }

        // 可持久化数组:查询版本 p 中位置 pos 的值
        int query(int p, int l, int r, int pos, int *valArr) {
            if (l == r) return valArr[p];
            int mid = (l + r) >> 1;
            if (pos <= mid) return query(lc[p], l, mid, pos, valArr);
            else            return query(rc[p], mid + 1, r, pos, valArr);
        }

        // 初始化第 0 个版本:fa[i] = i, sz[i] = 1
        void build(int &p, int l, int r, int *valArr, int type) {
            p = ++tot;
            if (l == r) {
                valArr[p] = (type == 0) ? l : 1;  // type=0 建 fa,type=1 建 sz
                return;
            }
            int mid = (l + r) >> 1;
            build(lc[p], l, mid, valArr, type);
            build(rc[p], mid + 1, r, valArr, type);
        }

        void init(int n) {
            tot = 0;
            build(rootFa[0], 1, n, faVal, 0);
            build(rootSz[0], 1, n, szVal, 1);
        }

        // 在版本 ver 中查询 x 的根(不带路径压缩,保证可持久化)
        int find(int ver, int x, int n) {
            while (true) {
                int f = query(rootFa[ver], 1, n, x, faVal);
                if (f == x) return x;
                x = f;
            }
        }

        // 在版本 ver 的基础上合并 x 和 y,返回新版本号
        // 注意:可持久化并查集通常不路径压缩,只按秩合并
        int unite(int ver, int x, int y, int n) {
            int fx = find(ver, x, n);
            int fy = find(ver, y, n);
            int newVer = ver + 1;
            rootFa[newVer] = rootFa[ver];
            rootSz[newVer] = rootSz[ver];

            if (fx == fy) return newVer;

            int sx = query(rootSz[ver], 1, n, fx, szVal);
            int sy = query(rootSz[ver], 1, n, fy, szVal);
            if (sx < sy) {
                swap(fx, fy);
                swap(sx, sy);
            }

            // fa[fy] = fx
            rootFa[newVer] = update(rootFa[newVer], 1, n, fy, fx, faVal);
            // sz[fx] += sz[fy]
            rootSz[newVer] = update(rootSz[newVer], 1, n, fx, sx + sy, szVal);
            return newVer;
        }

        // 查询第 ver 个版本中 x 与 y 是否连通
        bool same(int ver, int x, int y, int n) {
            return find(ver, x, n) == find(ver, y, n);
        }
    };
};

思路要点:

  • 可持久化并查集的本质:用可持久化数组 维护 fa 和 sz
  • 由于路径压缩会破坏历史版本,因此不能路径压缩 ,只能按秩合并 (按 sz 合并)
  • 单次 find 复杂度 O(log n),单次 unite 复杂度 O(log² n)
  • 每个版本由 rootFa[ver] 和 rootSz[ver] 唯一确定
  • 空间复杂度 O(n log n),每次合并新建 O(log n) 个节点
  • 与普通并查集对比:
    • 普通并查集:路径压缩 + 按秩合并,近似 O(1)
    • 可持久化并查集:只能按秩合并,O(log² n),但可回溯历史版本
  • 经典应用:可撤销并查集、强制在线历史版本连通性查询、线段树分治 + 可持久化并查集
4.5.4 区域赛题
  • 最大生成树---题目链接:第46届ICPC沈阳站H

  • 核心思路:

    复制代码
    【题目描述】
    在数学图论中,简单无向带权图 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;
}

4.6 单调栈和单调队列

4.6.1单调栈

适用场景: 找左右第一个更大/更小元素、柱状图最大矩形

cpp 复制代码
class Solver {
public:
    // 找每个元素右边第一个比它大的元素下标
    vector<int> next_greater(vector<int>& a) {
        int n = a.size();
        vector<int> res(n, -1);
        stack<int> st;
        for (int i = n - 1; i >= 0; i--) {
            while (!st.empty() && a[st.top()] <= a[i]) st.pop();
            if (!st.empty()) res[i] = st.top();
            st.push(i);
        }
        return res;
    }
    // 柱状图最大矩形面积
    long long largest_rectangle(vector<int>& h) {
        int n = h.size();
        vector<int> left(n), right(n);
        stack<int> st;
        for (int i = 0; i < n; i++) {
            while (!st.empty() && h[st.top()] >= h[i]) st.pop();
            left[i] = st.empty() ? -1 : st.top();
            st.push(i);
        }
        while (!st.empty()) st.pop();
        for (int i = n - 1; i >= 0; i--) {
            while (!st.empty() && h[st.top()] >= h[i]) st.pop();
            right[i] = st.empty() ? n : st.top();
            st.push(i);
        }
        long long ans = 0;
        for (int i = 0; i < n; i++)
            ans = max(ans, 1LL * h[i] * (right[i] - left[i] - 1));
        return ans;
    }
};

思路要点:

  • 维护栈内元素单调性(递增或递减)
  • 每个元素入栈出栈各一次,O(n)
  • 核心:弹出条件决定了"第一个不满足"的位置
4.6.2 单调队列

适用场景: 滑动窗口最值、固定窗口内的 min/max、DP 优化(形如 f[i] = a[i] + min{ f[j] : i-k ≤ j < i })

cpp 复制代码
class Solver {
public:
    // 滑动窗口最大值:返回每个长度为 k 的窗口的最大值
    vector<int> max_sliding_window(vector<int>& a, int k) {
        int n = a.size();
        vector<int> res;
        deque<int> dq;  // 存下标,对应的值单调递减
        for (int i = 0; i < n; i++) {
            // 队尾维护单调性:新元素比队尾大,就弹出队尾
            while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
            dq.push_back(i);
            // 队首超出窗口范围,弹出
            if (dq.front() <= i - k) dq.pop_front();
            // 窗口形成后开始记录答案
            if (i >= k - 1) res.push_back(a[dq.front()]);
        }
        return res;
    }
    // 滑动窗口最小值:返回每个长度为 k 的窗口的最小值
    vector<int> min_sliding_window(vector<int>& a, int k) {
        int n = a.size();
        vector<int> res;
        deque<int> dq;  // 存下标,对应的值单调递增
        for (int i = 0; i < n; i++) {
            // 队尾维护单调性:新元素比队尾小,就弹出队尾
            while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back();
            dq.push_back(i);
            // 队首超出窗口范围,弹出
            if (dq.front() <= i - k) dq.pop_front();
            // 窗口形成后开始记录答案
            if (i >= k - 1) res.push_back(a[dq.front()]);
        }
        return res;
    }
    // 单调队列优化 DP:f[i] = a[i] + min{ f[j] : i-k ≤ j < i }
    // 返回 f[1..n],f[0] = 0(虚拟起点)
    vector<long long> dp_sliding_min(vector<long long>& a, int n, int k) {
        vector<long long> f(n + 1, 0);
        deque<int> dq;  // 存下标,f 值单调递增
        dq.push_back(0);  // 虚拟起点 0,f[0] = 0
        for (int i = 1; i <= n; i++) {
            // 队首超出窗口 [i-k, i-1]
            while (!dq.empty() && dq.front() < i - k) dq.pop_front();
            // 从队首转移
            f[i] = f[dq.front()] + a[i];
            // 把 i 加入队尾,维护 f 值单调递增
            while (!dq.empty() && f[dq.back()] >= f[i]) dq.pop_back();
            dq.push_back(i);
        }
        return f;
    }
};

思路要点:

  • 用 deque 维护一个下标窗口,队首是窗口内最优值的下标
  • 入队前:从队尾弹出"不可能成为最优"的元素(维护单调性)
  • 出队前:从队首弹出"已经离开窗口"的元素(维护窗口范围)
  • 每个元素入队、出队各一次,均摊 O(1),总 O(n)
  • 求 max 时维护递减 队列,求 min 时维护递增队列
  • 和单调栈的区别:单调队列有"窗口大小"限制,栈没有
  • DP 优化时,队列里存的是候选转移下标,队首即最优转移来源
4.6.3 区域赛题
  • 利用单调队列---题目链接:2022南京站B

  • 【题目描述和思路】

    text 复制代码
    ============================================================
    B. Ropeway (2022 ICPC 南京站) 题目描述 + 思路
    ============================================================
    【一、题目描述】
    紫金山的索道要建支撑塔。起点在 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;
    }

4.7 基环树

适用场景: 图中有且仅有一个环的连通图,处理树上问题 + 环上问题的结合,如求环长、环上 DP、断环成树等

4.7.1 基本原理

核心思想: 基环树可以看作在一棵树上加一条边,使得图中恰好出现一个环。它的结构是 一个环,环上每个点挂着一棵子树。

两种常见形态:

  • 无向基环树 : n n n 个点, n n n 条边,连通,恰好一个环
  • 有向基环树(内向树 / 外向树) :
    • 内向树:每个点出度为 1,环上点指向环内,形成一棵"倒挂"的树
    • 外向树:每个点入度为 1,环上点指向环外

常用处理套路:

  1. 找环:拓扑排序 / DFS / 并查集
  2. 断环:把环上一条边断开,变成树,再考虑断开边的贡献
  3. 环上 DP:把环复制成链,做区间 DP / 单调队列优化
4.7.2 找环(拓扑排序法)

适用场景: 无向基环树,或内向树 / 外向树

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;
    vector<int> g[MAXN];
    int deg[MAXN];      // 入度
    bool onCycle[MAXN]; // 标记点是否在环上
    int n;
    // 拓扑排序找环:不断删掉度为 1 的点,剩下的就是环
    void findCycle() {
        queue<int> q;
        for (int i = 1; i <= n; i++) {
            if (deg[i] == 1) q.push(i);
        }

        while (!q.empty()) {
            int u = q.front();
            q.pop();
            onCycle[u] = false;  // 被删掉,不在环上

            for (int v : g[u]) {
                if (--deg[v] == 1) {
                    q.push(v);
                }
            }
        }
        // 最后 deg[i] >= 2 的点就是环上的点
    }
};

思路要点:

  • 无向基环树中,环上点度数 ≥ 2,非环点最终会被删到度数为 1
  • 拓扑排序不断删叶子,最后剩下的就是环
  • 时间复杂度 O(n)
4.7.3 找环(DFS 法)

适用场景: 有向基环树,或需要记录环上顺序

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;
    vector<int> g[MAXN];
    int dfn[MAXN], fa[MAXN];
    int cnt;              // 时间戳
    vector<int> cycle;    // 存储环上的点(按顺序)
    bool inCycle[MAXN];
    void dfs(int u) {
        dfn[u] = ++cnt;
        for (int v : g[u]) {
            if (!dfn[v]) {
                fa[v] = u;
                dfs(v);
            } else if (v != fa[u] && dfn[v] < dfn[u]) {
                // 找到环,从 u 回溯到 v
                cycle.clear();
                int x = u;
                while (x != v) {
                    cycle.push_back(x);
                    x = fa[x];
                }
                cycle.push_back(v);
                reverse(cycle.begin(), cycle.end());
            }
        }
    }
};

思路要点:

  • dfn[u] 记录 DFS 时间戳,fa[u] 记录父节点
  • 遇到已访问且不是父节点的点 v,且 dfn[v] < dfn[u],说明找到环
  • 从 u 沿 fa 回溯到 v,路径上的点就是环
  • 时间复杂度 O(n)
4.7.4 断环成树 + 树形 DP

适用场景: 基环树上求最大独立集、最小支配集、直径等

核心思路: 找到环上一条边 ( u , v ) (u, v) (u,v),分别讨论这条边选 / 不选,做两次树形 DP,取最优。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;
    vector<int> g[MAXN];
    int dp[MAXN][2];  // dp[u][0/1]:u 不选 / 选
    int n;
    int U, V;         // 断开的边 (U, V)
    // 树形 DP:求最大独立集
    void dfs(int u, int fa) {
        dp[u][0] = 0;
        dp[u][1] = 1;
        for (int v : g[u]) {
            if (v == fa || (u == U && v == V) || (u == V && v == U)) continue;
            dfs(v, u);
            dp[u][0] += max(dp[v][0], dp[v][1]);
            dp[u][1] += dp[v][0];
        }
    }
    int solve() {
        // 找到环上一条边 (U, V)
        // ...

        // 情况 1:强制 U 不选
        dfs(U, 0);
        int ans1 = max(dp[U][0], dp[U][1]);

        // 情况 2:强制 V 不选
        dfs(V, 0);
        int ans2 = max(dp[V][0], dp[V][1]);

        return max(ans1, ans2);
    }
};

思路要点:

  • 断开环上一条边 ( U , V ) (U, V) (U,V),图变成一棵树
  • 对树做树形 DP,但需要额外考虑断开边的约束
  • 常见做法:分别强制 U U U 不选、强制 V V V 不选,做两次 DP,取最大值
  • 时间复杂度 O(n)
4.7.5 环上 DP(复制成链 + 单调队列)

适用场景: 环上选点问题,如环上最大独立集、环上距离不超过 k 的点对

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;
    int a[MAXN * 2];       // 环复制成链
    long long sum[MAXN * 2]; // 前缀和
    int q[MAXN * 2];       // 单调队列
    int n, k;
    // 求环上距离不超过 k 的两点最大权值和
    long long solve() {
        // 复制成链
        for (int i = 1; i <= n; i++) {
            a[i + n] = a[i];
        }
        for (int i = 1; i <= 2 * n; i++) {
            sum[i] = sum[i - 1] + a[i];
        }

        long long ans = 0;
        int head = 0, tail = 0;
        q[tail++] = 0;

        for (int i = 1; i <= 2 * n; i++) {
            // 弹出超出距离 k 的
            while (head < tail && i - q[head] > k) head++;
            // 更新答案
            ans = max(ans, sum[i] - sum[q[head]]);
            // 维护单调队列
            while (head < tail && sum[q[tail - 1]] >= sum[i]) tail--;
            q[tail++] = i;
        }
        return ans;
    }
};

思路要点:

  • 环上问题常通过 复制成链 转化为链上问题
  • 用单调队列维护窗口内的最小值 / 最大值
  • 注意限制条件:距离不超过 k,对应队列中下标差 ≤ k
  • 时间复杂度 O(n)
4.7.6 基环树常见题型
题型 做法
求环长 拓扑排序 / DFS 找环
基环树最大独立集 断环成树 + 两次树形 DP
基环树直径 断环 + 树直径 + 环上单调队列
基环树两点距离 环上 + 树上分别处理
内向树 / 外向树 DP 拓扑排序找环 + 树形 DP
基环树计数 环上 DP + 组合数学
4.7.7 注意事项
  • 基环树 不一定连通,若图不连通则是基环树森林,需要对每个连通块分别处理
  • 无向基环树有 n n n 个点 n n n 条边;有向基环树(内向 / 外向)也有 n n n 个点 n n n 条边
  • 找环时注意 自环 和 重边 的处理
  • 断环成树时,断开边的两个端点要特殊标记,DP 时跳过这条边
  • 环上 DP 复制成链时,数组要开两倍大小
4.7.8 时间复杂度总结
操作 复杂度
拓扑排序找环 O(n)
DFS 找环 O(n)
断环成树 + 树形 DP O(n)
环上 DP(单调队列) O(n)
基环树直径 O(n)
4.7.9 区域赛题
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;
}

4.9 莫队

适用场景: 离线区间查询、区间不同数个数、区间众数、区间最大重要度、二次离线查询等

4.9.1 基本原理

核心思想: 把所有查询离线下来,按某种顺序排序,然后用两个指针 winl、winr 在数组上左右移动,维护当前区间的答案。每次移动指针时增量更新答案。

关键点:

  • 排序方式决定复杂度
  • 普通莫队:按左端点所在块排序,块内按右端点排序
  • 奇偶优化:奇数块右端点升序,偶数块右端点降序
  • 指针移动总次数 O((n + q) × √n)

时间复杂度: O((n + q) × √n × 单次增删代价)

4.9.2 普通莫队(区间不同数个数)

题意: 给定长度 n 的数组,q 次查询 [l, r],求区间内不同数字的个数。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        cin >> n;
        for (int i = 1; i <= n; i++) cin >> arr[i];
        cin >> q;
        for (int i = 1; i <= q; i++) {
            cin >> query[i].l >> query[i].r;
            query[i].id = i;
        }
        prepare();
        compute();
        for (int i = 1; i <= q; i++) cout << ans[i] << '\n';
        return 0;
    }

private:
    struct Query {
        int l, r, id;
    };

    static constexpr int MAXN = 30001;
    static constexpr int MAXV = 1000001;
    static constexpr int MAXQ = 200001;

    int n, q;
    int arr[MAXN];
    Query query[MAXQ];

    int bi[MAXN];       // 每个位置所属的块
    int cnt[MAXV];      // 每个值的出现次数
    int kind = 0;       // 当前区间不同数的个数

    int ans[MAXQ];

    // 奇偶优化排序
    bool QueryCmp(Query &a, Query &b) {
        if (bi[a.l] != bi[b.l]) return bi[a.l] < bi[b.l];
        if (bi[a.l] & 1) return a.r < b.r;   // 奇数块升序
        else             return a.r > b.r;   // 偶数块降序
    }

    // 删除一个数
    void del(int num) {
        if (--cnt[num] == 0) kind--;
    }

    // 加入一个数
    void add(int num) {
        if (++cnt[num] == 1) kind++;
    }

    void prepare() {
        int blen = (int)sqrt(n);
        for (int i = 1; i <= n; i++) {
            bi[i] = (i - 1) / blen + 1;
        }
        sort(query + 1, query + q + 1, [this](auto& __x, auto& __y) { return QueryCmp(__x, __y); } );
    }

    void compute() {
        int winl = 1, winr = 0;
        for (int i = 1; i <= q; i++) {
            int jobl = query[i].l;
            int jobr = query[i].r;
            while (winl > jobl) add(arr[--winl]);
            while (winr < jobr) add(arr[++winr]);
            while (winl < jobl) del(arr[winl++]);
            while (winr > jobr) del(arr[winr--]);
            ans[query[i].id] = kind;
        }
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 维护窗口 [winl, winr],每次调整到当前查询区间
  • add 和 del 增量更新答案
  • 排序用奇偶优化,减少右指针回退
  • 时间复杂度 O((n + q) × √n)
4.9.3 只增回滚莫队(区间最大重要度)

题意: 定义数字 x 的重要度为 x * 出现次数,查询区间 [l, r] 上的最大重要度。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        cin >> n >> m;
        for (int i = 1; i <= n; i++) cin >> arr[i];
        for (int i = 1; i <= m; i++) {
            cin >> query[i].l >> query[i].r;
            query[i].id = i;
        }
        prepare();
        compute();
        for (int i = 1; i <= m; i++) cout << ans[i] << '\n';
        return 0;
    }

private:
    struct Query {
        int l, r, id;
    };

    static constexpr int MAXN = 100001;
    static constexpr int MAXB = 401;

    int n, m;
    int arr[MAXN];
    Query query[MAXN];
    int sorted[MAXN];   // 离散化后的值
    int cntv;           // 不同值的个数

    int blen, bnum;
    int bi[MAXN];
    int br[MAXB];       // 每块的右端点

    int cnt[MAXN];
    long long curAns = 0;

    long long ans[MAXN];

    bool QueryCmp(Query &a, Query &b) {
        if (bi[a.l] != bi[b.l]) return bi[a.l] < bi[b.l];
        return a.r < b.r;
    }

    // 二分找离散化后的值
    int kth(int num) {
        int left = 1, right = cntv, ret = 0;
        while (left <= right) {
            int mid = (left + right) >> 1;
            if (sorted[mid] <= num) {
                ret = mid;
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return ret;
    }

    // 暴力求解小区间
    long long force(int l, int r) {
        long long ret = 0;
        for (int i = l; i <= r; i++) cnt[arr[i]]++;
        for (int i = l; i <= r; i++) {
            ret = max(ret, 1LL * cnt[arr[i]] * sorted[arr[i]]);
        }
        for (int i = l; i <= r; i++) cnt[arr[i]]--;
        return ret;
    }

    void add(int num) {
        cnt[num]++;
        curAns = max(curAns, 1LL * cnt[num] * sorted[num]);
    }

    void del(int num) {
        cnt[num]--;
    }

    void compute() {
        for (int block = 1, qi = 1; block <= bnum && qi <= m; block++) {
            curAns = 0;
            fill(cnt + 1, cnt + cntv + 1, 0);
            int winl = br[block] + 1, winr = br[block];
            for (; qi <= m && bi[query[qi].l] == block; qi++) {
                int jobl = query[qi].l;
                int jobr = query[qi].r;
                int id = query[qi].id;
                if (jobr <= br[block]) {
                    // 完全在块内,暴力
                    ans[id] = force(jobl, jobr);
                } else {
                    // 右端点扩展
                    while (winr < jobr) add(arr[++winr]);
                    long long backup = curAns;
                    // 左端点扩展(只增)
                    while (winl > jobl) add(arr[--winl]);
                    ans[id] = curAns;
                    // 回滚
                    curAns = backup;
                    while (winl <= br[block]) del(arr[winl++]);
                }
            }
        }
    }

    void prepare() {
        for (int i = 1; i <= n; i++) sorted[i] = arr[i];
        sort(sorted + 1, sorted + n + 1);
        cntv = 1;
        for (int i = 2; i <= n; i++) {
            if (sorted[cntv] != sorted[i]) sorted[++cntv] = sorted[i];
        }
        for (int i = 1; i <= n; i++) {
            arr[i] = kth(arr[i]);
        }
        blen = (int)sqrt(n);
        bnum = (n + blen - 1) / blen;
        for (int i = 1; i <= n; i++) bi[i] = (i - 1) / blen + 1;
        for (int i = 1; i <= bnum; i++) br[i] = min(i * blen, n);
        sort(query + 1, query + m + 1, [this](auto& __x, auto& __y) { return QueryCmp(__x, __y); } );
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 只增回滚莫队适用于"加入容易、删除困难"的题目
  • 按左端点分块,块内按右端点排序
  • 右端点只增不减,左端点每次从块边界暴力扩展后回滚
  • 完全在块内的查询用暴力
  • 时间复杂度 O(n√n)
4.9.4 莫队二次离线(区间 k1 二元组)

题意: 定义 k1 二元组为 (i, j),满足 arr[i] ^ arr[j] 的二进制中恰好有 k 个 1。查询区间 [l, r] 内有多少 k1 二元组。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        cin >> n >> m >> k;
        for (int i = 1; i <= n; i++) cin >> arr[i];
        for (int i = 1; i <= m; i++) {
            cin >> query[i].l >> query[i].r;
            query[i].id = i;
        }
        prepare();
        compute();
        for (int i = 1; i <= m; i++) cout << ans[i] << '\n';
        return 0;
    }

private:
    struct Query {
        int l, r, id;
    };

    static constexpr int MAXN = 100002;
    static constexpr int MAXV = 1 << 14;

    int n, m, k;
    int arr[MAXN];
    int bi[MAXN];
    int kOneArr[MAXV];
    int cntk;

    Query query[MAXN];

    // 二次离线的链表
    int headl[MAXN], headr[MAXN];
    int nextq[MAXN << 1];
    int ql[MAXN << 1], qr[MAXN << 1];
    int qop[MAXN << 1], qid[MAXN << 1];
    int cntq;

    int cnt[MAXV];
    long long pre[MAXN], suf[MAXN];
    long long ans[MAXN];

    bool QueryCmp(Query &a, Query &b) {
        if (bi[a.l] != bi[b.l]) return bi[a.l] < bi[b.l];
        return a.r < b.r;
    }

    int lowbit(int x) { return x & -x; }

    int countOne(int num) {
        int ret = 0;
        while (num > 0) { ret++; num -= lowbit(num); }
        return ret;
    }

    void addLeftOffline(int x, int l, int r, int op, int id) {
        nextq[++cntq] = headl[x];
        headl[x] = cntq;
        ql[cntq] = l; qr[cntq] = r; qop[cntq] = op; qid[cntq] = id;
    }

    void addRightOffline(int x, int l, int r, int op, int id) {
        nextq[++cntq] = headr[x];
        headr[x] = cntq;
        ql[cntq] = l; qr[cntq] = r; qop[cntq] = op; qid[cntq] = id;
    }

    void prepare() {
        int blen = (int)sqrt(n);
        for (int i = 1; i <= n; i++) bi[i] = (i - 1) / blen + 1;
        sort(query + 1, query + m + 1, [this](auto& __x, auto& __y) { return QueryCmp(__x, __y); } );
        for (int v = 0; v < MAXV; v++) {
            if (countOne(v) == k) kOneArr[++cntk] = v;
        }
    }

    void compute() {
        // 预处理前缀贡献
        for (int i = 1; i <= n; i++) {
            pre[i] = pre[i - 1] + cnt[arr[i]];
            for (int j = 1; j <= cntk; j++) cnt[arr[i] ^ kOneArr[j]]++;
        }
        memset(cnt, 0, sizeof(cnt));
        // 预处理后缀贡献
        for (int i = n; i >= 1; i--) {
            suf[i] = suf[i + 1] + cnt[arr[i]];
            for (int j = 1; j <= cntk; j++) cnt[arr[i] ^ kOneArr[j]]++;
        }
        // 莫队主过程
        int winl = 1, winr = 0;
        for (int i = 1; i <= m; i++) {
            int jobl = query[i].l;
            int jobr = query[i].r;
            int id = query[i].id;
            if (winr < jobr) {
                addLeftOffline(winl - 1, winr + 1, jobr, -1, id);
                ans[id] += pre[jobr] - pre[winr];
            }
            if (winr > jobr) {
                addLeftOffline(winl - 1, jobr + 1, winr, 1, id);
                ans[id] -= pre[winr] - pre[jobr];
            }
            winr = jobr;
            if (winl > jobl) {
                addRightOffline(winr + 1, jobl, winl - 1, -1, id);
                ans[id] += suf[jobl] - suf[winl];
            }
            if (winl < jobl) {
                addRightOffline(winr + 1, winl, jobl - 1, 1, id);
                ans[id] -= suf[winl] - suf[jobl];
            }
            winl = jobl;
        }
        // 处理左端点二次离线
        memset(cnt, 0, sizeof(cnt));
        for (int x = 0; x <= n; x++) {
            if (x >= 1) {
                for (int j = 1; j <= cntk; j++) cnt[arr[x] ^ kOneArr[j]]++;
            }
            for (int q = headl[x]; q > 0; q = nextq[q]) {
                int l = ql[q], r = qr[q], op = qop[q], id = qid[q];
                for (int j = l; j <= r; j++) ans[id] += 1LL * op * cnt[arr[j]];
            }
        }
        // 处理右端点二次离线
        memset(cnt, 0, sizeof(cnt));
        for (int x = n + 1; x >= 1; x--) {
            if (x <= n) {
                for (int j = 1; j <= cntk; j++) cnt[arr[x] ^ kOneArr[j]]++;
            }
            for (int q = headr[x]; q > 0; q = nextq[q]) {
                int l = ql[q], r = qr[q], op = qop[q], id = qid[q];
                for (int j = l; j <= r; j++) ans[id] += 1LL * op * cnt[arr[j]];
            }
        }
        // 答案前缀和
        for (int i = 2; i <= m; i++) {
            ans[query[i].id] += ans[query[i - 1].id];
        }
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 二次离线莫队适用于"单次增删代价较大"的题目
  • 把增删操作转化为预处理 + 离线查询
  • 用前缀 / 后缀贡献加速
  • 最后对答案做前缀和
  • 时间复杂度 O(n√n + n × cntk)
4.9.5 莫队的常见变体
变体 适用场景 关键点
普通莫队 区间不同数、区间众数 奇偶优化
带修莫队 单点修改 + 区间查询 三维排序,时间戳
回滚莫队 只增不删 / 只删不增 分块 + 回滚
二次离线莫队 增删代价大 预处理 + 离线
树上莫队 树上路径查询 欧拉序 + LCA
树上带修莫队 树上路径 + 修改 欧拉序 + 时间戳
4.9.6 时间复杂度总结
变体 复杂度
普通莫队 O((n + q) × √n)
带修莫队 O(n^(5/3))
回滚莫队 O(n√n)
二次离线莫队 O(n√n + n × cntk)
树上莫队 O((n + q) × √n)
4.9.7 注意事项
  • 莫队是离线算法,不能处理强制在线的问题
  • 排序时用奇偶优化,可以显著减少右指针移动
  • 块大小一般取 √n,也可以根据题目调整
  • 增删操作要 O(1) 或 O(log n),否则复杂度会退化
  • 二次离线莫队要注意预处理和离线查询的细节
  • 回滚莫队要保证"回滚"操作正确
  • 多测试用例时注意清空 cnt 等数组

4.x ST表(Sparse Table,稀疏表)

适用场景: 静态区间查询(可重复贡献问题),如区间最值、区间 GCD、区间按位与/或等

4.x.1 基本原理

核心思想: 倍增 + 动态规划。预处理出每个位置开始、长度为 2 p 2^p 2p 的区间的信息,查询时用两个重叠的区间合并。

关键性质:

  • ST表只适用于可重复贡献问题 :即合并操作满足结合律,且 x ⊕ x = x x \oplus x = x x⊕x=x(重叠部分不影响结果)
  • 典型例子:max、min、gcd、按位与、按位或
  • 反例:区间和(重叠部分会重复计算)

预处理:

  • st[i][0] = a[i]
  • st[i][p] = merge(st[i][p-1], st[i + 2^{p-1}][p-1])

查询 [l, r]:

  • 令 p = ⌊ log ⁡ 2 ( r − l + 1 ) ⌋ p = \lfloor \log_2(r - l + 1) \rfloor p=⌊log2(r−l+1)⌋
  • 答案 = merge(st[l][p], st[r - 2^p + 1][p])
  • 两个区间长度都是 2 p 2^p 2p,覆盖 l , r l, r l,r,重叠部分不影响结果

时间复杂度: 预处理 O(n log n),单次查询 O(1),空间 O(n log n)

4.x.2 区间最大值 / 最小值(洛谷 P2880)

问题: 给定数组,每次查询区间 [l, r] 的最大值和最小值,输出最大值 − 最小值。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    int run() {
        int n, m;
        cin >> n >> m;
        for (int i = 1; i <= n; i++) cin >> arr[i];

        build(n);

        while (m--) {
            int l, r;
            cin >> l >> r;
            cout << query(l, r) << '\n';
        }
        return 0;
    }

private:
    static constexpr int MAXN = 50005;
    static constexpr int LIMIT = 16;   // 2^15 = 32768 <= 50000,2^16 = 65536 > 50000

    int arr[MAXN];
    int log2_[MAXN];
    int stmax[MAXN][LIMIT];
    int stmin[MAXN][LIMIT];

    // 预处理 log2 表和 ST 表
    void build(int n) {
        log2_[0] = -1;
        for (int i = 1; i <= n; i++) {
            log2_[i] = log2_[i >> 1] + 1;   // log2[i] = log2[i/2] + 1
            stmax[i][0] = arr[i];
            stmin[i][0] = arr[i];
        }
        // 倍增预处理
        for (int p = 1; p <= log2_[n]; p++) {
            for (int i = 1; i + (1 << p) - 1 <= n; i++) {
                stmax[i][p] = max(stmax[i][p - 1], stmax[i + (1 << (p - 1))][p - 1]);
                stmin[i][p] = min(stmin[i][p - 1], stmin[i + (1 << (p - 1))][p - 1]);
            }
        }
    }

    // 查询区间 [l, r] 的最大值 - 最小值
    int query(int l, int r) {
        int p = log2_[r - l + 1];
        int mx = max(stmax[l][p], stmax[r - (1 << p) + 1][p]);
        int mn = min(stmin[l][p], stmin[r - (1 << p) + 1][p]);
        return mx - mn;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    Solver solver;
    return solver.run();
}

思路要点:

  • 维护两棵 ST 表:一棵维护最大值,一棵维护最小值
  • 查询时分别查最大值和最小值,相减
  • 时间复杂度 O(n log n + m)
4.x.3 区间 GCD(洛谷 P1890)

问题: 给定数组,每次查询区间 [l, r] 内所有数的最大公约数。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class GCD {
public:
    int run() {
        int n, m;
        cin >> n >> m;
        for (int i = 1; i <= n; i++) cin >> arr[i];

        build(n);

        while (m--) {
            int l, r;
            cin >> l >> r;
            cout << query(l, r) << '\n';
        }
        return 0;
    }

private:
    static constexpr int MAXN = 1005;
    static constexpr int LIMIT = 10;   // 2^10 = 1024 > 1000

    int arr[MAXN];
    int log2_[MAXN];
    int stgcd[MAXN][LIMIT];

    int gcd(int a, int b) {
        return b == 0 ? a : gcd(b, a % b);
    }

    void build(int n) {
        log2_[0] = -1;
        for (int i = 1; i <= n; i++) {
            log2_[i] = log2_[i >> 1] + 1;
            stgcd[i][0] = arr[i];
        }
        for (int p = 1; p <= log2_[n]; p++) {
            for (int i = 1; i + (1 << p) - 1 <= n; i++) {
                stgcd[i][p] = gcd(stgcd[i][p - 1], stgcd[i + (1 << (p - 1))][p - 1]);
            }
        }
    }

    int query(int l, int r) {
        int p = log2_[r - l + 1];
        return gcd(stgcd[l][p], stgcd[r - (1 << p) + 1][p]);
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    GCD solver;
    return solver.run();
}

思路要点:

  • GCD 满足结合律和可重复贡献:gcd(x, x) = x
  • 可以直接用 ST 表维护
  • 时间复杂度 O(n log n + m log V)
4.x.4 区间按位与 / 按位或

问题: 给定数组,每次查询区间 [l, r] 内所有数的按位与(或按位或)。

cpp 复制代码
class Solver {
public:
    // 按位与:x & x = x,满足可重复贡献
    int stand[MAXN][LIMIT];
    // 按位或:x | x = x,满足可重复贡献
    int stor[MAXN][LIMIT];
    void build(int n) {
        for (int i = 1; i <= n; i++) {
            stand[i][0] = arr[i];
            stor[i][0] = arr[i];
        }
        for (int p = 1; p <= log2_[n]; p++) {
            for (int i = 1; i + (1 << p) - 1 <= n; i++) {
                stand[i][p] = stand[i][p - 1] & stand[i + (1 << (p - 1))][p - 1];
                stor[i][p] = stor[i][p - 1] | stor[i + (1 << (p - 1))][p - 1];
            }
        }
    }
};

思路要点:

  • 按位与、按位或都满足可重复贡献
  • 直接套用 ST 表模板
4.x.5 ST表 vs 线段树
特性 ST表 线段树
适用场景 静态、可重复贡献 动态、任意合并
预处理 O(n log n) O(n)
单次查询 O(1) O(log n)
单点修改 不支持 O(log n)
区间修改 不支持 O(log n)
空间 O(n log n) O(4n)
实现难度 简单 中等

选择建议:

  • 如果数组静态且操作可重复贡献(max/min/gcd/and/or),用 ST 表
  • 如果需要修改或操作不可重复贡献(如区间和),用线段树
4.x.6 常见应用总结
应用 合并操作 经典题
区间最大值 max 洛谷 P2880
区间最小值 min 洛谷 P2880
区间 GCD gcd 洛谷 P1890
区间按位与 & 多题
区间按位或 | 多题
区间最大值 + 最小值 max, min 洛谷 P2880
4.x.7 复杂度总结
操作 复杂度
预处理 log2 O(n)
预处理 ST 表 O(n log n)
单次查询 O(1)
空间 O(n log n)
4.x.8 注意事项
  • ST 表只适用于静态数组,不支持修改
  • 合并操作必须满足可重复贡献 : x ⊕ x = x x \oplus x = x x⊕x=x
  • LIMIT 要开到 ⌊ log ⁡ 2 n ⌋ + 1 \lfloor \log_2 n \rfloor + 1 ⌊log2n⌋+1
  • 查询时两个区间可以重叠,因为可重复贡献
  • log2_[0] = -1 是边界条件
  • 多组测试要清空 arr、log2_、st 数组
  • 值可能很大,要用 long long
  • 空间 O(n log n) 在 n 很大时可能爆内存,注意 LIMIT 取值

技巧补充---数链剖分

适用场景: 树上路径修改 / 查询、子树修改 / 查询、LCA、树上第 k 大等

基本原理

核心思想: 把树按"重儿子"划分成若干条重链,每条重链对应 DFS 序上的一段连续区间。这样树上任意一条路径都可以拆成 O(log n) 段重链,用线段树 / 树状数组维护这些区间。

关键定义:

  • 重儿子:子树大小最大的儿子
  • 轻儿子:除重儿子外的其他儿子
  • 重链:从某个节点出发,一直走重儿子形成的链
  • 链顶:重链上深度最小的节点
  • DFS 序:优先访问重儿子得到的 DFS 序

关键性质:

  • 每条重链在 DFS 序上是连续的一段
  • 每个子树在 DFS 序上也是连续的一段
  • 任意节点到根的路径最多经过 O(log n) 条重链
  • 任意路径可以拆成 O(log n) 段重链
预处理
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solver {
public:
    static constexpr int MAXN = 100005;
    vector<int> g[MAXN];
    int fa[MAXN], dep[MAXN], sz[MAXN], son[MAXN];
    int top[MAXN], dfn[MAXN], rnk[MAXN], cnt;
    int a[MAXN];       // 原始点权
    int w[MAXN];       // 按 DFS 序重排后的点权
    // 第一遍 DFS:求 fa, dep, sz, son
    void dfs1(int u, int f) {
        fa[u] = f;
        dep[u] = dep[f] + 1;
        sz[u] = 1;
        son[u] = 0;
        for (int v : g[u]) {
            if (v == f) continue;
            dfs1(v, u);
            sz[u] += sz[v];
            if (sz[v] > sz[son[u]]) son[u] = v;
        }
    }
    // 第二遍 DFS:求 top, dfn, rnk
    void dfs2(int u, int t) {
        top[u] = t;
        dfn[u] = ++cnt;
        rnk[cnt] = u;
        w[cnt] = a[u];
        if (son[u]) dfs2(son[u], t);   // 优先访问重儿子
        for (int v : g[u]) {
            if (v == fa[u] || v == son[u]) continue;
            dfs2(v, v);                // 轻儿子作为新链的链顶
        }
    }
};

思路要点:

  • dfs1 自底向上求子树大小和重儿子
  • dfs2 优先访问重儿子,保证重链在 DFS 序上连续
  • 轻儿子的链顶是它自己
  • 时间复杂度 O(n)
路径查询 / 修改

核心: 把路径 (u, v) 拆成若干段重链,用线段树维护。

cpp 复制代码
class Solver {
public:
    // 线段树(维护区间和)
    struct SegTree {
        long long sum[MAXN << 2], lazy[MAXN << 2];

        void push_up(int p) { sum[p] = sum[p<<1] + sum[p<<1|1]; }

        void push_down(int p, int l, int r) {
            if (lazy[p]) {
                int mid = (l + r) >> 1;
                sum[p<<1] += lazy[p] * (mid - l + 1);
                sum[p<<1|1] += lazy[p] * (r - mid);
                lazy[p<<1] += lazy[p];
                lazy[p<<1|1] += lazy[p];
                lazy[p] = 0;
            }
        }

        void build(int p, int l, int r) {
            if (l == r) { sum[p] = w[l]; return; }
            int mid = (l + r) >> 1;
            build(p<<1, l, mid);
            build(p<<1|1, mid+1, r);
            push_up(p);
        }

        void update(int p, int l, int r, int ul, int ur, long long val) {
            if (ul <= l && r <= ur) {
                sum[p] += val * (r - l + 1);
                lazy[p] += val;
                return;
            }
            push_down(p, l, r);
            int mid = (l + r) >> 1;
            if (ul <= mid) update(p<<1, l, mid, ul, ur, val);
            if (ur > mid) update(p<<1|1, mid+1, r, ul, ur, val);
            push_up(p);
        }

        long long query(int p, int l, int r, int ql, int qr) {
            if (ql <= l && r <= qr) return sum[p];
            push_down(p, l, r);
            int mid = (l + r) >> 1;
            long long res = 0;
            if (ql <= mid) res += query(p<<1, l, mid, ql, qr);
            if (qr > mid) res += query(p<<1|1, mid+1, r, ql, qr);
            return res;
        }
    };
    // 路径修改:u 到 v 的路径上所有点加 val
    void updatePath(int u, int v, long long val) {
        while (top[u] != top[v]) {
            if (dep[top[u]] < dep[top[v]]) swap(u, v);
            seg.update(1, 1, n, dfn[top[u]], dfn[u], val);
            u = fa[top[u]];
        }
        if (dep[u] > dep[v]) swap(u, v);
        seg.update(1, 1, n, dfn[u], dfn[v], val);
    }
    // 路径查询:u 到 v 的路径上所有点的和
    long long queryPath(int u, int v) {
        long long res = 0;
        while (top[u] != top[v]) {
            if (dep[top[u]] < dep[top[v]]) swap(u, v);
            res += seg.query(1, 1, n, dfn[top[u]], dfn[u]);
            u = fa[top[u]];
        }
        if (dep[u] > dep[v]) swap(u, v);
        res += seg.query(1, 1, n, dfn[u], dfn[v]);
        return res;
    }
};

思路要点:

  • 不断把链顶深度较大的节点往上跳
  • 每次跳一段重链(dfn[top[u]] 到 dfn[u])
  • 最后两个点在同一个重链上,直接查询 [dfn[u], dfn[v]]
  • 时间复杂度 O(log² n)
子树查询 / 修改

结论: 子树在 DFS 序上是连续的区间 [dfn[u], dfn[u] + sz[u] - 1]。

cpp 复制代码
class Solver {
public:
    // 子树修改:u 的子树所有点加 val
    void updateSubtree(int u, long long val) {
        seg.update(1, 1, n, dfn[u], dfn[u] + sz[u] - 1, val);
    }
    // 子树查询:u 的子树所有点的和
    long long querySubtree(int u) {
        return seg.query(1, 1, n, dfn[u], dfn[u] + sz[u] - 1);
    }
};

思路要点:

  • 子树在 DFS 序上是连续的
  • 直接用线段树区间操作
  • 时间复杂度 O(log n)
求 LCA
cpp 复制代码
class LCA {
public:
    // 树链剖分求 LCA
    int lca(int u, int v) {
        while (top[u] != top[v]) {
            if (dep[top[u]] < dep[top[v]]) swap(u, v);
            u = fa[top[u]];
        }
        return dep[u] < dep[v] ? u : v;
    }
};

思路要点:

  • 不断把链顶深度较大的节点往上跳
  • 直到两个点在同一条重链上
  • 返回深度较小的那个
  • 时间复杂度 O(log n)
边权转点权

思路: 把边权下放到深度较大的端点上,路径查询时排除 LCA 的点权。

cpp 复制代码
class Solver {
public:
    // 边权转点权:边 (u, v) 的权值赋给深度较大的端点
    // 路径查询时,最后一段是 [dfn[u] + 1, dfn[v]](排除 LCA)
    long long queryPathEdge(int u, int v) {
        long long res = 0;
        while (top[u] != top[v]) {
            if (dep[top[u]] < dep[top[v]]) swap(u, v);
            res += seg.query(1, 1, n, dfn[top[u]], dfn[u]);
            u = fa[top[u]];
        }
        if (dep[u] > dep[v]) swap(u, v);
        // 排除 LCA 的点权(即 u)
        res += seg.query(1, 1, n, dfn[u] + 1, dfn[v]);
        return res;
    }
};

思路要点:

  • 边权下放到深度较大的端点
  • 路径查询时排除 LCA 的点权
  • 注意 LCA 是 u 还是 v
常见套路总结
类型 做法
路径修改 / 查询 拆成 O(log n) 段重链
子树修改 / 查询 DFS 序连续区间
LCA 跳重链
边权转点权 下放到深度较大的端点
树上第 k 大 树链剖分 + 主席树
树上路径计数 树链剖分 + 线段树
动态树 LCT(Link-Cut Tree)

统一思路:

  1. 两次 DFS :求出 fa, dep, sz, son, top, dfn, rnk
  2. 建线段树:按 DFS 序建树
  3. 路径操作:跳重链,拆成若干段区间
  4. 子树操作:直接用 DFS 序区间

复杂度:

  • 预处理:O(n)
  • 路径修改 / 查询:O(log² n)
  • 子树修改 / 查询:O(log n)
  • LCA:O(log n)
注意事项
  • dfs1 和 dfs2 可能爆栈,n 很大时用迭代 DFS 或增大栈空间
  • son[u] 初始为 0,sz[0] = 0,避免越界
  • 重儿子的 DFS 序紧跟在父亲后面
  • 路径操作时注意比较的是 dep[top[u]] 而不是 dep[u]
  • 边权转点权时,最后一段要排除 LCA 的点权
  • 多组测试要清空 g 和所有数组

相关推荐
总裁余(余登武)2 小时前
K-means算法改进(Python版)
python·算法·kmeans
灯澜忆梦2 小时前
【面向对象编程c++】| 指针与自定义数据类型
开发语言·c++·算法
xxwl5853 小时前
区域赛算法模版第八章---贪心(Greedy)
c++·笔记·学习·算法
雾沉川3 小时前
Visual C++运行库合集 v105.0 安装教程
开发语言·c++·dll
_wyt0013 小时前
洛谷P17461 [GESP202609 八级] 生成树计数 题解
c++·数学·树·gesp·八级·仙人掌
CCblandy3 小时前
向量检索 ANN 参数实测:换一种数据分布,recall 掉了 51 个百分点
人工智能·python·算法·自然语言处理·性能优化
CS创新实验室3 小时前
一棵树怎么做到“自平衡还不太费力“?——红黑树的旋转与染色
数据结构·算法·红黑树·408·计算机考研
Logic1013 小时前
C语言/数据结构位运算题解:异或XOR找出被挑选一次的果子编号——只出现一次的数字
c语言·数据结构·算法·数组·位运算·时间复杂度·空间复杂度
深圳市快瞳科技有限公司3 小时前
面向端侧部署的多帧宠物身份识别:完整性评分机制与工程约束
人工智能·算法·计算机视觉·端侧ai·宠物识别·宠物ai·宠物身份识别