文章目录
-
- 四、区域赛算法模版第四章---数据结构
-
- [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)
补充说明:
-
数组大小 :
N << 5是常见开法,因为每次修改新建约log n个节点,总共约n log n个节点。若数据范围较大,可改为N * 20或N * 25等。 -
静态区间第 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];
}
};
- 与普通线段树的区别 :
- 普通线段树节点编号固定为
p << 1和p << 1 | 1,主席树动态开点,用lc[p]、rc[p]记录儿子 - 普通线段树支持区间修改 + 懒标记,主席树通常只支持单点修改(区间修改需要标记永久化等技巧)
- 主席树每个版本共享大量节点,空间换时间
- 普通线段树节点编号固定为
4.1.2 区域赛题
- DP+线段树 ---题目链接:第46届ICPC澳门站G
- 题目描述及思路:
text
Cyclic Buffer(ICPC 澳门 2021 G / 牛客 31454 G / 洛谷 P9662)
【题目描述】
有一个大小为 n 的循环缓冲区,前 k 个位置(位置 1 到 k)有"读者",可以访问。
初始时,缓冲区第 i 个位置上的整数是 a_i,且 a_1, a_2, ..., a_n 构成一个 1 到 n 的排列。
你需要按照 1, 2, ..., n 的顺序访问所有整数。一个整数只有在它位于前 k 个位置时才能被访问。
如果当前要访问的整数不在前 k 个位置,你可以将整个缓冲区向左或向右循环移动任意多次。
- 左移一次:第 i 个位置的数移到第 i-1 个位置(i > 1),第 1 个位置的数移到第 n 个位置。
- 右移一次:第 i 个位置的数移到第 i+1 个位置(i < n),第 n 个位置的数移到第 1 个位置。
问:最少需要移动多少次缓冲区,才能按顺序访问完所有整数?
【输入】
第一行:T,测试用例个数。
每个测试用例:
第一行:n, k(1 ≤ k ≤ n ≤ 10^6)
第二行:n 个整数 a_1, ..., a_n,构成 1 到 n 的排列。
保证所有测试用例的 n 之和 ≤ 10^6。
【输出】
每个测试用例输出一行,一个整数,表示最少移动次数。
【样例1】
输入:
2
5 3
2 4 3 5 1
1 1
1
输出:
3
0
【思路解说】
【一、问题转化:窗口模型】
循环缓冲区的"前 k 个位置"可以看作一个长度为 k 的窗口。
设窗口的起始位置为 s(1 ≤ s ≤ n),则窗口覆盖原位置 s, s+1, ..., s+k-1(模 n 意义下)。
对于每个整数 v,它在原缓冲区中的位置是 pos[v]。
要让 v 进入窗口,窗口起始位置 s 必须满足:
s ∈ W_v = [pos[v] - k + 1, pos[v]](模 n 意义下)
也就是说,窗口的起始位置 s 必须落在某个区间内。
【二、访问顺序与移动代价】
我们要按 1, 2, ..., n 的顺序访问。
每次访问一个整数 v 后,窗口的起始位置 s 可能会变化(因为我们可以移动缓冲区)。
设访问完整数 i 后,窗口起始位置为 s_i。
访问下一个整数 i+1 时,如果 i+1 已经在窗口内,则不需要移动,s_{i+1} = s_i。
否则,需要把窗口移动到某个 s_{i+1} ∈ W_{i+1},移动代价为环上 s_i 到 s_{i+1} 的最短距离。
所以,问题转化为:在环上选择一系列窗口起始位置 s_1, s_2, ..., s_n,
使得 s_v ∈ W_v,且总移动代价 Σ dist(s_{v-1}, s_v) 最小。
【三、关键结论:最优解中窗口边界对齐】
官方题解的一个重要结论是:
最优解中,每次移动后,目标整数 v 会恰好落在窗口的第 1 位或第 k 位。
也就是说,对于每个整数 v,我们只需要考虑两种窗口位置:
- s = pos[v](v 在第 1 位)
- s = pos[v] - k + 1(v 在第 k 位)
所以,我们可以定义 DP 状态:
f[v][0]:访问完 v 后,v 在第 1 位的最小代价。
f[v][1]:访问完 v 后,v 在第 k 位的最小代价。
【四、连续访问的压缩:g 数组】
如果访问完 v 后,发现 v+1, v+2, ..., v+m 都已经在窗口内,
那么我们可以直接连续访问它们,不需要任何移动。
只有当遇到第一个不在窗口内的整数时,才需要移动。
定义:
g0[v]:从 v 在第 1 位的状态出发,能连续访问到的下一个不在窗口内的整数。
g1[v]:从 v 在第 k 位的状态出发,能连续访问到的下一个不在窗口内的整数。
如果 g0[v] 不存在,说明从 v 开始可以一直访问到 n,花费 0。
【五、g 数组的计算】
g0[v] 的计算:
- 当 v 在第 1 位时,窗口覆盖原位置 pos[v] 到 pos[v]+k-1。
- 我们需要找到最小的整数 u > v,使得 u 不在这个窗口内。
- 这等价于:在值域上,找到第一个不在窗口内的值。
- 可以用滑动窗口 + 值域线段树(求 >= x 的第一个缺失值)来 O(n log n) 计算。
g1[v] 的计算类似,只是窗口覆盖原位置 pos[v]-k+1 到 pos[v]。
【六、DP 转移】
对于每个整数 i,如果 f[i][0] 或 f[i][1] 有效:
- 令 v = g0[i](或 g1[i])。
- 如果 v == -1,说明可以一直访问到 n,用 f[i][st] 更新答案。
- 否则,需要把 v 移入窗口,计算移动代价:
- 从 i 的位置(第 1 位或第 k 位)移动到 v 的第 1 位或第 k 位。
- 环上距离用 min(|x-y|, n-|x-y|) 计算。
- 更新 f[v][0] 和 f[v][1]。
【七、算法流程】
1) 读入 n, k 和排列 a,计算每个值的位置 pos[v]。
2) 如果 n == k,直接输出 0(所有位置都是读者)。
3) 用滑动窗口 + 值域线段树计算 g0 和 g1 数组。
4) 找到初始窗口内能免费访问的最大前缀,确定第一个需要移动的整数 fir。
5) 初始化 f[fir][0] 和 f[fir][1]。
6) 按 i 从小到大遍历,用 g0/g1 转移 DP。
7) 输出最小代价。
【八、复杂度】
- 计算 g0, g1:O(n log n)
- DP 转移:O(n)
- 总复杂度:O(n log n),可以过。
【九、注意事项】
- 值域线段树维护的是"哪些值在窗口内",需要支持查询 >= x 的第一个缺失值。
- 滑动窗口移动时,要同时更新线段树(加入新值,移除旧值)。
- 环上距离要取 min(d, n-d)。
- 答案可能很大,要用 long long。
- 多组测试要清空线段树和数组。
- n == k 时直接输出 0。
- 初始窗口内能免费访问的前缀要正确处理。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 值域线段树:维护 0..n-1 中每个值是否存在(1 存在,0 缺失)
// 支持查询 >= pos 的第一个缺失值(即第一个 0)
struct SegTree {
int n, sz; // n: 值域大小,sz: 线段树大小(2 的幂)
vector<int> t; // 线段树数组,t[p] = 区间内最小值(0 表示有缺失,1 表示全存在)
void init(int n_) {
n = n_;
sz = 1;
while (sz < n) sz <<= 1; // 把 sz 扩展到 >= n 的最小 2 的幂
t.assign(2 * sz, 0); // 初始全部为 0(全部缺失)
}
// 把值 i 的状态设为 v(v=1 表示存在,v=0 表示缺失)
void setv(int i, int v) {
int p = i + sz; // 叶子节点
t[p] = v;
for (p >>= 1; p; p >>= 1) { // 向上更新
t[p] = min(t[p << 1], t[p << 1 | 1]);
}
}
// 从节点 p 出发,找到它下面最左边的 0(即最左的缺失值)
int descend(int p) {
while (p < sz) {
p = (t[p << 1] == 0) ? (p << 1) : (p << 1 | 1);
}
int idx = p - sz;
return idx < n ? idx : -1; // 超出值域范围返回 -1
}
// 查询 >= pos 的第一个缺失值(0-indexed),无则返回 -1
int firstZero(int pos) {
if (pos >= n) return -1;
int l = pos + sz, r = n + sz; // 查询区间 [pos, n-1]
int ln[64], rn[64], lc = 0, rc = 0;
// 把区间拆成线段树上的若干节点
while (l < r) {
if (l & 1) ln[lc++] = l++;
if (r & 1) rn[rc++] = --r;
l >>= 1; r >>= 1;
}
// 从左到右检查左边界节点
for (int i = 0; i < lc; i++) {
if (t[ln[i]] == 0) return descend(ln[i]);
}
// 从右到左检查右边界节点
for (int i = rc - 1; i >= 0; i--) {
if (t[rn[i]] == 0) return descend(rn[i]);
}
return -1;
}
};
int main() {
int T;
if (scanf("%d", &T) != 1) return 0;
SegTree SEG;
while (T--) {
int n, k;
scanf("%d %d", &n, &k);
vector<int> a(n + 1), pos(n + 1);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
pos[a[i]] = i; // pos[v] = 值 v 在缓冲区中的位置
}
// 如果 n == k,所有位置都是读者,不需要移动
if (n == k) {
printf("0\n");
continue;
}
// 把 a 复制一份接在后面,方便处理环形
vector<int> p(2 * n + 2);
for (int i = 1; i <= n; i++) p[i] = a[i];
for (int i = 1; i <= n; i++) p[i + n] = a[i];
// g0[v]: 从 v 在第 1 位的状态出发,能连续访问到的下一个不在窗口内的整数
// g1[v]: 从 v 在第 k 位的状态出发,能连续访问到的下一个不在窗口内的整数
vector<int> g0(n + 1, -1), g1(n + 1, -1);
// ---- 计算 g0 ----
// 当值 x 在第 1 位时,窗口覆盖原位置 pos[x] .. pos[x]+k-1
// 我们需要找到最小的值 u > x,使得 u 不在这个窗口内
// 等价于:在值域上找到第一个不在窗口内的值
SEG.init(n);
// 初始窗口:从 p[n] 开始,长度为 k 的窗口
// 这里 p[n] 是 a[n],窗口覆盖 a[n], a[1], ..., a[k-1]
for (int i = 1; i <= k; i++) {
SEG.setv(p[n + i - 1] - 1, 1); // 把窗口内的值标记为存在
}
{
// 当前窗口对应的值是 p[n](在第 1 位)
// 找 >= p[n] 的第一个缺失值
int idx = SEG.firstZero(p[n] - 1);
g0[p[n]] = (idx < 0 ? -1 : idx + 1); // idx+1 转回 1-indexed
}
// 窗口从右向左滑动
for (int i = n - 1; i >= 1; i--) {
// 窗口左移:加入 p[i],移除 p[i+k]
SEG.setv(p[i] - 1, 1); // 新值进入窗口
SEG.setv(p[i + k] - 1, 0); // 旧值离开窗口
int idx = SEG.firstZero(p[i] - 1);
g0[p[i]] = (idx < 0 ? -1 : idx + 1);
}
// ---- 计算 g1 ----
// 当值 x 在第 k 位时,窗口覆盖原位置 pos[x]-k+1 .. pos[x]
// 我们需要找到最小的值 u > x,使得 u 不在这个窗口内
SEG.init(n);
// 初始窗口:以 p[n+1] 结尾,长度为 k 的窗口
for (int i = 1; i <= k; i++) {
SEG.setv(p[n - i + 2] - 1, 1);
}
{
int idx = SEG.firstZero(p[n + 1] - 1);
g1[p[n + 1]] = (idx < 0 ? -1 : idx + 1);
}
// 窗口从左向右滑动
for (int i = n + 2; i <= 2 * n; i++) {
SEG.setv(p[i] - 1, 1); // 新值进入窗口
SEG.setv(p[i - k] - 1, 0); // 旧值离开窗口
int idx = SEG.firstZero(p[i] - 1);
g1[p[i]] = (idx < 0 ? -1 : idx + 1);
}
// 环上两点 x, y 的最短距离
auto mv = [&](int x, int y) {
int d = abs(x - y);
return min(d, n - d);
};
const ll INF = (ll)4e18;
// f[i][0]: 访问完 i 后,i 在第 1 位的最小代价
// f[i][1]: 访问完 i 后,i 在第 k 位的最小代价
vector<array<ll, 2>> f(n + 1, {(ll)INF, (ll)INF});
// 找到初始窗口内能免费访问的最大前缀
// 即找到第一个位置 > k 的值
int fir = 0;
for (int v = 1; v <= n; v++) {
if (pos[v] > k) {
fir = v;
break;
}
}
// 如果所有值都在前 k 个位置内,直接输出 0
if (fir == 0) {
printf("0\n");
continue;
}
// 初始化第一个需要移动的值
f[fir][0] = mv(pos[fir], 1); // 把 fir 移到第 1 位
f[fir][1] = mv(pos[fir], k); // 把 fir 移到第 k 位
ll ans = INF;
// DP 转移
for (int i = fir; i <= n; i++) {
for (int st = 0; st < 2; st++) {
if (f[i][st] >= INF) continue;
// 从状态 (i, st) 出发,找到下一个需要移动的值 v
int v = (st == 0) ? g0[i] : g1[i];
if (v < 0) {
// 没有下一个需要移动的值,说明可以一直访问到 n
ans = min(ans, f[i][st]);
continue;
}
// 计算从 i 当前位置到 v 的目标位置的环上距离
// i 当前在位置 st==0 ? 1 : k
// v 的目标位置是 v 在第 1 位或第 k 位
int tmp = (pos[v] + (st == 0 ? 1 : k) - pos[i]) % n;
if (tmp <= 0) tmp += n; // 规范到 1..n
f[v][0] = min(f[v][0], f[i][st] + mv(tmp, 1));
f[v][1] = min(f[v][1], f[i][st] + mv(tmp, k));
}
}
printf("%lld\n", ans);
}
return 0;
}
- 贪心+可持续化线段树 ---题目链接:第45届国际大学生编程设计竞赛昆明站M
- 题目描述及思路:
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)
- 若序列中存在相同元素,堆性质通常取
<或<=需与题意一致,否则结构可能不唯一
典型应用:
- RMQ 转 LCA :对序列建小根堆笛卡尔树,区间
[l, r]的最小值所在位置就是lca(l, r) - 直方图最大矩形:对高度数组建笛卡尔树,枚举每个节点作为最矮柱子向两边扩展
- 区间最值分治:笛卡尔树天然把序列按最小值分治成左右两部分
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 区域赛题
- 逆序对问题 ---题目链接:2022年中国大学生编程设计竞赛桂林站M
- 题目描述及思路:
text
Youth Finale(2022 CCPC 桂林站 M / CF Gym 104008 M)
【题目描述】
冒泡排序是一种经典的排序算法。给定一个长度为 n 的排列 p,定义"交换次数"为
冒泡排序过程中执行 swap 的总次数。
现在有一个初始排列 p,以及 m 次操作,操作有两种:
- 'R':反转排列,即 p' = {p_n, p_{n-1}, ..., p_2, p_1}
- 'S':左移一位,即 p' = {p_2, p_3, ..., p_n, p_1}
每次操作后,你需要输出:如果对这个新排列执行冒泡排序,会执行多少次 swap。
输出交换次数对 10 取模的结果。
【输入】
第一行:n, m(1 ≤ n ≤ 3×10^5,1 ≤ m ≤ 6×10^5)
第二行:n 个整数,表示初始排列 p
第三行:一个长度为 m 的字符串,只包含 'R' 和 'S',表示 m 次操作
保证 p 是 1..n 的一个排列。
【输出】
第一行:初始排列的冒泡排序交换次数
第二行:一个长度为 m 的字符串,第 i 位表示第 i 次操作后的交换次数对 10 取模
【样例1】
输入:
5 10
5 4 3 2 1
SSSSRSSSSR
输出:
10
6446466400
【思路解说】
【一、核心观察:冒泡排序交换次数 = 逆序对数量】
冒泡排序中,每次 swap 恰好消除一个逆序对(相邻逆序对)。
最终逆序对全部消除,排序完成。
所以,冒泡排序的交换次数 = 排列的**逆序对总数**。
因此问题转化为:维护一个排列的逆序对数量,支持两种操作:
- 反转排列
- 左移一位
【二、反转排列对逆序对的影响】
设原排列的逆序对数为 inv。
反转后,原来的顺序对变成逆序对,逆序对变成顺序对。
总对数 = n(n-1)/2。
所以反转后的逆序对数:
inv' = n(n-1)/2 - inv
**注意:** 这个公式是"全局反转"的公式。
在代码中,我们用 `front` 指针和 `dir` 方向来表示当前排列,
反转操作只需要改变 `dir` 和 `front` 指针,同时更新 `inv`。
【三、左移一位对逆序对的影响】
左移一位:把第一个元素 p[front] 移到最后。
设当前第一个元素的值为 x。
- 原来 x 与其他元素的逆序对:
- x 与后面比它小的元素构成逆序对,数量 = x - 1。
- 左移后,x 移到最后,它与前面所有元素的关系变成:
- x 与前面比它大的元素构成逆序对,数量 = n - x。
- 所以逆序对的变化:
inv' = inv - (x - 1) + (n - x) = inv + n - 2x + 1。
**公式:** inv' = inv + n - 2*x + 1,其中 x 是当前第一个元素的值。
【四、用环形数组 + 方向标记维护当前排列】
用 `front` 表示当前排列第一个元素在数组 a 中的下标。
用 `dir` 表示方向:dir = 1 表示正常方向,dir = -1 表示反转方向。
**初始状态:**
front = 0, dir = 1
**左移操作 'S':**
- 当前第一个元素是 a[front]。
- 更新 inv:inv += n + 1 - 2 * a[front]。
- 移动 front:front = (front + dir) % n(注意处理负数)。
**反转操作 'R':**
- 更新 inv:inv = n*(n-1)/2 - inv。
- 反转后,原来的最后一个元素变成第一个元素。
最后一个元素的下标 = (front + (n-1)*dir) % n。
- 所以 front 需要跳到这个位置。
- 同时 dir = -dir。
**注意:** 在代码中,front 的更新用 `((front + dir) % n + n) % n` 处理负数。
反转时 front 的更新用 `((front + (n-1)*dir) % n + n) % n`。
【五、初始逆序对的计算:树状数组】
用树状数组从右往左统计逆序对:
- 对于每个 a[i],查询树状数组中比 a[i] 小的元素个数(即 query(a[i]-1))。
- 累加到 inv。
- 然后把 a[i] 加入树状数组。
**为什么从右往左?**
因为我们要统计"右边比当前元素小的个数",从右往左遍历可以保证
树状数组中存储的都是当前元素右边的元素。
【六、算法流程】
1) 读入 n, m 和排列 a,以及操作字符串 ops。
2) 用树状数组计算初始逆序对 inv。
3) 输出 inv。
4) 初始化 front = 0, dir = 1。
5) 对于每个操作:
- 如果是 'S':
x = a[front]
inv += n + 1 - 2*x
front = ((front + dir) % n + n) % n
- 如果是 'R':
inv = n*(n-1)/2 - inv
front = ((front + (n-1)*dir) % n + n) % n
dir = -dir
- 输出 inv % 10(注意负数处理)。
6) 输出所有结果拼成的字符串。
【七、复杂度】
- 初始逆序对:O(n log n)
- 每次操作:O(1)
- 总复杂度:O(n log n + m),可以过。
【八、注意事项】
- 逆序对数量可能很大,要用 long long。
- 反转公式:inv' = n*(n-1)/2 - inv。
- 左移公式:inv' = inv + n - 2*x + 1,其中 x 是当前第一个元素。
- front 指针的更新要处理负数取模。
- 输出对 10 取模,注意负数情况:((inv % 10) + 10) % 10。
- 多组测试要清空数组。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class YouthFinale {
public:
void run() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
a.resize(n);
for (int i = 0; i < n; i++) cin >> a[i];
cin >> ops;
// 计算初始排列的逆序对数量(= 冒泡排序交换次数)
long long inv = countInversions();
cout << inv << "\n";
int front = 0; // 当前排列第一个元素在数组 a 中的下标
int dir = 1; // 方向:1 表示正常方向,-1 表示反转方向
string out;
out.reserve(m);
for (char op : ops) {
if (op == 'S') {
// ===== 左移一位 =====
// 当前第一个元素是 a[front]
// 左移后,它移到最后,逆序对变化:inv += n + 1 - 2*x
int x = a[front];
inv += (long long)n + 1 - 2LL * x;
// front 沿着方向移动一位
front = ((front + dir) % n + n) % n;
} else {
// ===== 反转排列 =====
// 总对数 = n*(n-1)/2,反转后逆序对 = 总对数 - 原逆序对
inv = (long long)n * (n - 1) / 2 - inv;
// 反转后,原来的最后一个元素变成第一个元素
// 最后一个元素的下标 = front + (n-1)*dir(模 n)
front = ((front + (long long)(n - 1) * dir) % n + n) % n;
// 方向取反
dir = -dir;
}
// 输出当前逆序对数量对 10 取模(注意处理负数)
long long d = ((inv % 10) + 10) % 10;
out.push_back(char('0' + d));
}
cout << out << "\n";
}
private:
int n = 0, m = 0;
string ops;
vector<int> a;
vector<long long> bit; // 树状数组
// 树状数组单点更新:位置 i 增加 v
void update(int i, long long v) {
for (; i <= n; i += i & (-i)) bit[i] += v;
}
// 树状数组前缀查询:1..i 的和
long long query(int i) {
long long s = 0;
for (; i > 0; i -= i & (-i)) s += bit[i];
return s;
}
// 用树状数组计算初始排列的逆序对数量
// 从右往左遍历,统计右侧比 a[i] 小的元素个数
long long countInversions() {
bit.assign(n + 1, 0);
long long inv = 0;
for (int i = n - 1; i >= 0; i--) {
inv += query(a[i] - 1); // 右侧比 a[i] 小的个数
update(a[i], 1); // 把 a[i] 加入树状数组
}
return inv;
}
};
int main() {
YouthFinale solver;
solver.run();
return 0;
}
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,环上点指向环外
常用处理套路:
- 找环:拓扑排序 / DFS / 并查集
- 断环:把环上一条边断开,变成树,再考虑断开边的贡献
- 环上 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 区域赛题
- 基环树 ---题目链接:第9届CCPC深圳站F
- 题目描述及思路:
text
见面礼(CSGCPC 1242 / 基环树)
【题目描述】
给定一个包含 n 个点、n 条边的无向连通图。
你需要删除图中的一条边,使得剩下的图是一棵树,并且存在一个根节点 p,
使得以 p 为根时,树上每个节点的儿子数都不超过 3。
问:有多少种「删边 + 选根」的方案满足条件?
【输入】
第一行一个整数 n(1 ≤ n ≤ 10^5)。
接下来 n 行,每行两个整数 u, v,表示一条无向边。
【输出】
输出一个整数,表示满足条件的方案数。
【样例1】
输入:
6
1 2
1 3
1 4
1 5
1 6
2 3
输出:
10
【样例解释】
删除 (1, 2) 或者 (1, 3),并在 {2, 3, 4, 5, 6} 中选择一个节点作为根,
可以得到所有的合法方案。因此输出为 2 × 5 = 10。
【思路解说】
【一、图的结构:基环树】
n 个点 n 条边且连通 ⇒ 图中恰好有一个环,环上挂着若干棵树。
要变成一棵树,必须且只能删除环上的一条边。
【二、删边后的树满足什么条件】
删掉一条边后,图变成一棵树。选一个根 p,要求所有节点的儿子数 ≤ 3。
对于树中的节点 i:
- 如果 i 是根:儿子数 = 度数(删边后)
- 如果 i 不是根:儿子数 = 度数 - 1(减去连向父亲的那条边)
所以「儿子数 ≤ 3」等价于:
- 根节点:删边后度数 ≤ 3
- 非根节点:删边后度数 ≤ 4
【三、删边的影响】
删掉边 (u, v) 后,只有 u 和 v 的度数减 1,其他点的度数不变。
所以:
- 原图中度数 ≥ 6 的点:删一条边最多减 1,无法降到 ≤ 4,无解。
- 原图中度数 = 5 的点:删边后必须变成 4,所以它必须是 u 或 v。
因此,原图中度数 = 5 的点必须全部在 {u, v} 中。
【四、统计合法根的数量】
设删边后:
- u 的新度数 = du[u] - 1
- v 的新度数 = du[v] - 1
- 其他点度数不变
合法根的条件:删边后度数 ≤ 3。
预处理原图中度数 ≤ 3 的点的个数 cntLE3。
对于删边 (u, v):
- 如果 du[u] == 4,删边后变成 3,u 从「不合法根」变成「合法根」,贡献 +1
- 如果 du[v] == 4,同理 +1
- 其他点度数不变,合法根个数不变
所以:
validRoots = cntLE3 + (du[u] == 4) + (du[v] == 4)
【五、算法流程】
1) 读入 n 和 n 条边,统计每个点的度数 du。
2) 用拓扑排序找环:
- 把所有度数为 1 的点入队。
- 不断删掉度数为 1 的点,最后剩下的就是环上的点。
3) 统计原图中度数 = 5 的点集合 deg5,以及度数 ≤ 3 的点数 cntLE3。
4) 枚举环上的每条边 (u, v):
- 检查 deg5 中的点是否都在 {u, v} 中,否则跳过。
- 计算 validRoots = cntLE3 + (du[u] == 4) + (du[v] == 4)。
- 累加到答案。
5) 输出答案。
【六、复杂度】
- 拓扑排序找环:O(n)
- 枚举环上边:O(n)
- 检查 deg5:由于题目保证有解,|deg5| ≤ 2,所以 O(1)
- 总复杂度:O(n),可以过 10^5。
【七、注意事项】
- n 个点 n 条边的连通图一定是基环树。
- 删边后要变成树,必须删环上的边,不能删树边。
- 度数 = 5 的点必须全部在删边的两个端点中。
- 合法根的统计可以用 O(1) 公式,不需要每次 O(n) 重新统计。
- 根据以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
vector<int> g[MAXN]; // 邻接表存图
int du[MAXN]; // 原图中每个点的度数
int du1[MAXN]; // 拓扑排序用的度数副本(会不断减)
bool onCycle[MAXN]; // 标记点是否在环上
int n;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// 读入 n 个点、n 条边(基环树)
cin >> n;
for (int i = 0; i < n; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
du[u]++; du[v]++; // 原图度数
du1[u]++; du1[v]++; // 拓扑排序用的度数
}
// ===== 拓扑排序找环 =====
// 不断删掉度数为 1 的点(叶子),最后剩下的就是环上的点
queue<int> q;
for (int i = 1; i <= n; i++) {
if (du1[i] == 1) q.push(i);
}
while (!q.empty()) {
int u = q.front(); q.pop();
onCycle[u] = false; // 被删掉的点不在环上
for (int v : g[u]) {
if (--du1[v] == 1) q.push(v);
}
}
// 最后 du1[i] >= 2 的点一定在环上
for (int i = 1; i <= n; i++) {
if (du1[i] >= 2) onCycle[i] = true;
}
// ===== 统计原图中度数 = 5 的点 =====
// 这些点删边后必须变成 4(非根节点儿子数 ≤ 3 ⇒ 度数 ≤ 4)
// 所以它们必须是被删边的两个端点之一
set<int> deg5;
for (int i = 1; i <= n; i++) {
if (du[i] == 5) deg5.insert(i);
}
// ===== 预处理:原图中度数 ≤ 3 的点的个数 =====
// 这些点在删边后度数只会 ≤ 3,天然是合法根
int cntLE3 = 0;
for (int i = 1; i <= n; i++) {
if (du[i] <= 3) cntLE3++;
}
ll ans = 0;
// ===== 枚举环上的每条边 (u, v) =====
for (int u = 1; u <= n; u++) {
if (!onCycle[u]) continue; // 只枚举环上的点
for (int v : g[u]) {
if (v < u || !onCycle[v]) continue; // 避免重复枚举环上的边
// 检查删掉边 (u, v) 是否合法:
// 原图中度数 = 5 的点必须全部在 {u, v} 中
bool ok = true;
for (int x : deg5) {
if (x != u && x != v) { ok = false; break; }
}
if (!ok) continue;
// O(1) 统计删边后合法根的数量:
// 原度数 ≤ 3 的点天然合法
// 如果 u 原度数为 4,删边后变 3,新增一个合法根
// 如果 v 原度数为 4,同理
int validRoots = cntLE3;
if (du[u] == 4) validRoots++;
if (du[v] == 4) validRoots++;
ans += validRoots;
}
}
cout << ans << '\n';
return 0;
}
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) |
统一思路:
- 两次 DFS :求出
fa, dep, sz, son, top, dfn, rnk - 建线段树:按 DFS 序建树
- 路径操作:跳重链,拆成若干段区间
- 子树操作:直接用 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和所有数组