数据结构使用技巧
原文链接:数据结构使用技巧
1. 导论:剖析问题的维度
在深入探讨具体技巧之前,我们首先需要建立一个分析框架。任何数据结构问题,本质上都是在多维空间中对信息进行维护与查询。这些“维度”不仅包括数据本身的数值、坐标,也包括“时间”这一特殊维度。我们所使用的技巧,其核心目标便是通过巧妙的变换,分解或削减问题的维度,使其落入某个我们能够高效处理的模型范畴。
评判一个技巧是否适用,通常取决于问题所具备的几个关键性质:
- 离线 vs 在线 (Offline vs. Online) 这是最根本的约束。若所有操作(修改与查询)均可预先获知,则为离线问题。这给予我们重排、批量处理操作的自由,是 CDQ 分治、整体二分等技巧的基石。反之,若操作必须按顺序依次处理,则为在线问题,需要二进制分组、可持久化等方法。
- 贡献可分解性 (Decomposability of Contribution) 一个查询的答案, 是否能表示为一系列独立修改所产生贡献的简单合并 (如求和、取最值)? 若修改 M 对查询 Q 的贡献 C(M, Q) 可以独立计算, 且多个贡献可以简单地通过某个满足结合律的运算 \(\oplus\) 合并 (即 \(\operatorname{ans}(Q) = C(M1, Q) \oplus C(M2, Q) \oplus \dots\) ), 那么问题便具有贡献可分解性。这是 CDQ 分治与二进制分组的理论基础。
- 操作可撤销性 (Reversibility of Operations) 一个修改操作所带来的状态变化, 能否被高效地 “逆转”? 例如, 在并查集中加入一条边, 我们可以通过记录操作前的状态, 在之后将其恢复。这种性质是线段树分治的核心。注意, 可撤销性不等于任意删除。某些数据结构 (如不带路径压缩的并查集) 的操作是可撤销的, 但其均摊复杂度分析可能依赖于操作的特定序列, 撤销必须严格遵循后入先出的栈式顺序。
理解这些性质, 能帮助我们在面对问题时, 迅速判断其 “命门” 所在, 从而选取最合适的 “武器” 与 “战术”。
2. 逆向思维:重塑时间与空间
逆向思维并非单一算法,而是一种通用的问题转化策略。当正向处理的逻辑流程复杂或状态转移繁琐时,尝试从相反的视角审视问题,可能发现豁然开朗的简洁路径。
2.1. 时间倒流
时间倒流是最直观的逆向思维应用。它适用于一类动态问题:其操作序列在正向执行时包含难以处理的类型(如删除、分裂),但其逆操作却相对简单(如增加、合并)。
- 适用场景:离线问题;操作的逆操作比原操作更容易用数据结构维护。
- 核心思想:将整个操作序列存储下来,从最终状态开始,逆序执行每个操作的“逆操作”,将问题转化为一个我们熟悉的正向模型。
实例分析:P1197 [JSO12008]星球大战
题意概括:给定一个图,初始时所有点都存在。之后进行 \(k\) 次操作,每次摧毁一个点(以及所有与之相连的边)。每次操作后,询问图中连通块的数量。
分析:正向操作是“删点”,这在图论中通常很棘手,因为它会破坏已有的连通性。然而,其逆操作是“加点”,即恢复一个点及其所有相连的边。这等价于向图中加入若干条边,并询问连通块数量。使用并查集(Disjoint Set Union, DSU)可以轻松维护加边操作对连通块数量的影响:每成功连接两个分属不同集合的点,连通块总数减一。
解题流程:
- 读入图的初始状态和所有将被摧毁的点。
- 逻辑上先 “摧毁” 所有将被摧毁的点, 构建出图的最终形态。此时用并查集计算初始的连通块数量。
- 将摧毁操作序列逆序。对于每一个“摧毁点 \(p\) ”的操作,其逆操作是“恢复点 \(p\) ”。
- 执行逆操作:将点 p 标记为存在,并遍历所有与 p 相连的边 (p, v)。若点 v 也存在,则在并查集中合并 p 和 v。
- 在每次逆操作(即正向操作之前)记录当前的连通块数量。
- 最后将记录的答案序列反向输出。
#include<bits/stdc++.h>
using namespacestd;
using ll = long long;
const int N = 400005, M = 200005;
int n, m;
int f[N]; // 并查集父节点数组
vector<int> g[N]; // 邻接表
int des[N]; // 记录被摧毁的星球
bool vis[N]; // 记录星球是否被摧毁
int ans[N];
int fnd(int x) {
return x == f[x] ? x : f[x] = fnd(f[x]);
}
void mrg(int x, int y, int& cnt) {
x = fnd(x); y = fnd(y);
if (x != y) {
f[x] = y;
cnt--;
}
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin >> n >> m;
for (int i = 0; i < n; i++) f[i] = i;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
int k;
cin >> k;
for (int i = 0; i < k; i++) {
cin >> des[i];
vis[des[i]] = 1; // 标记为已摧毁
}
int cnt = 0; // 连通块计数
for (int i = 0; i < n; i++) {
if (!vis[i]) {
cnt++; // 未被摧毁的点先各自成为一个连通块
for (int v : g[i]) {
if (!vis[v]) {
mrg(i, v, cnt);
}
}
}
}
ans[k] = cnt; // 记录最终状态的答案
for (int i = k - 1; i >= 0; i--) {
int u = des[i];
vis[u] = 0; // 恢复点 u
cnt++; // 恢复的点首先自成一个连通块
for (int v : g[u]) {
if (!vis[v]) {
mrg(u, v, cnt);
}
}
ans[i] = cnt;
}
for (int i = 0; i <= k; i++) {
cout << ans[i] << "\n";
}
return 0;
}
-
时间复杂度: \(O(n + m + k \cdot \alpha(n))\) , 其中 \(\alpha(n)\) 是并查集的反阿克曼函数。主要开销在于建图、初始化并查集和处理所有加边操作。
-
空间复杂度: \(O(n + m)\) , 用于存储图和并查集。
2.2.贡献转换
贡献转换,或称主客体反转,是一种更深刻的思维变换。它改变了我们统计贡献的视角。当“从查询 Q的角度,枚举所有对其有贡献的数据点 D”这一思路难以实现时,我们可以反过来思考:“对于每一个数据点 D,它能对哪些查询 Q 产生贡献?”
• 适用场景:问题的查询和数据具有某种对称的几何或逻辑关系。
• 核心思想: 将数据点和查询的身份互换, 把对复杂区域的查询问题, 转化为对多个简单区域的叠加问题, 后者通常可以用扫描线等技术解决。
实例分析:P1502 窗口的星星
题意概括:平面上有 n 个点,每个点有亮度。给定一个宽高为 W,H 的矩形窗口,问窗口最多能框住多大亮度的星星。
分析:正向思路是枚举窗口的位置。由于窗口左上角可以放在任意位置,决策空间是连续且无限的,无法直接枚举。
采用贡献转换。我们不移动窗口,而是思考每个点(x_i,y_i)能被哪些窗口捕捉到。一个点(x_i,y_i)能被一个左上角为(x,y)、宽高为W,H的窗口框住,当且仅当:
这可以转化为对窗口左上角 \((x, y)\) 的约束:
这意味着, 每个点 i 的存在, 都相当于对所有左上角落在矩形 \([x_{i} - W, x_{i}] \times [y_{i} - H, y_{i}]\) 内的窗口, 贡献了其亮度 c_i。
问题因此转化为:平面上有 n 个带权矩形,求平面上哪个点被矩形覆盖的权值之和最大。这是一个经典的扫描线问题。我们将每个矩形的左右边界看作事件点。从左到右扫描,用一个数据结构(如线段树)维护扫描线上的权值分布。
解题流程:
- 对于每个点 \((x_i, y_i)\) ,亮度 \(c_i\) ,生成一个矩形 \([x_i - W, x_i] \times [y_i - H, y_i]\) ,权值为 \(c_i\) 。
- 将每个矩形拆分为两条竖直边事件: 在 \(x = x_i - w\) 处, 对 \(y\) 轴上 \([y_i - H, y_i]\) 区间加上 \(c_i\) ; 在 \(x = x_i\) 处, 对 \(y\) 轴上 \([y_i - H, y_i]\) 区间减去 \(c_i\) 。
- 将所有 \(y\) 坐标离散化。
- 将所有事件按 \(x\) 坐标排序。
- 依次处理事件。在处理两个相邻事件 x_1, x_2 之间,扫描线扫过的区域 [x_1, x_2) 内,y 轴上的权值分布是不变的。此时查询线段树的全局最大值,即为该区域内能达到的最大亮度。更新全局答案。
参考代码
#include <bits/stdc++.h>
using namespacestd;
using ll = long long;
// 定义事件结构体,包含x坐标,y轴起止点,亮度变化值
struct Ev {
ll x, b, e;
int v;
// 按x坐标排序
bool operator<(const Ev& o) const {
return x < o.x;
}
};
// 线段树节点
struct Nod {
ll add, mxv;
};
int n;
ll W, H;
vector<Ev> es; // 事件列表
vector<ll> Y; // y坐标离散化
Nod tr[80005]; // 修正了数组大小
// 上传更新
void pu(int u) {
tr[u].mxv = max(tr[u * 2].mxv, tr[u * 2 + 1].mxv);
}
// 应用懒惰标记
void app(int u, int v) {
tr[u].add += v;
tr[u].mxv += v;
}
// 下传懒惰标记
void pd(int u) {
if (tr[u].add) {
app(u * 2, tr[u].add);
app(u * 2 + 1, tr[u].add);
tr[u].add = 0;
}
}
// 区间更新
void upd(int u, int l, int r, int ql, int qr, int v) {
if (ql > qr) return; // 安全检查
if (ql <= l && r <= qr) {
app(u, v);
return;
}
pd(u);
int mid = (l + r) >> 1;
if (ql <= mid) upd(u * 2, l, mid, ql, qr, v);
if (qr > mid) upd(u * 2 + 1, mid + 1, r, ql, qr, v);
pu(u);
}
// 单次测试解决函数
void slv() {
cin >> n >> W >> H;
es.clear();
Y.clear();
for (int i = 0; i < n; i++) {
ll x, y, l;
cin >> x >> y >> l;
// 星星(x,y)对窗口(wx,wy)的贡献区间是 wx in (x-W, x), wy in (y-H, y)
// 转换成扫描线问题,矩形叠加
es.push_back({x - W, y - H, y, (int)l});
es.push_back({x, y - H, y, (int)-l});
Y.push_back(y - H);
Y.push_back(y);
}
sort(Y.begin(), Y.end());
Y.erase(unique(Y.begin(), Y.end()), Y.end());
sort(es.begin(), es.end());
int m = Y.size();
if (m == 0) {
cout << 0 << "\n";
return;
}
// 初始化线段树
for (int i = 0; i <= 4 * m; ++i) tr[i] = {0, 0};
ll ans = 0;
for (size_t i = 0; i < es.size(); i++) {
// 找到y坐标对应的离散化后索引
int yb = lower_bound(Y.begin(), Y.end(), es[i].b) - Y.begin();
int ye = lower_bound(Y.begin(), Y.end(), es[i].e) - Y.begin();
// 对[yb, ye-1]的y轴区间进行更新
upd(1, 0, m - 2, yb, ye - 1, es[i].v);
// 在处理完相同x坐标的所有事件后,再更新答案
if (i + 1 == es.size() || es[i].x != es[i + 1].x) {
ans = max(ans, tr[1].mxv);
}
}
cout << ans << "\n";
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int t;
cin >> t;
while (t--) {
slv();
}
return 0;
}
3. CDQ 分治
CDQ 分治是一种强大的离线算法框架, 其命名源自其发明者陈丹琦。它专门用于处理一类动态问题, 特别是高维偏序问题。
适用场景:
- 问题离线。
- 修改操作对查询的贡献是独立的,可以分批计算后合并。
- 将问题分为两半后,计算一侧修改对另一侧查询的贡献,是一个比原问题更简单的静态子问题。
核心思想:分治。将时间(或某一维坐标)一分为二,递归处理两个子区间。关键在于计算跨越中点的贡献:即 \([1, \text{mid}]\) 区间的修改对 \([\text{mid} + 1, \text{r}]\) 区间的查询的影响。由于在计算跨区贡献时,时间顺序是固定的(左侧所有修改都早于右侧所有查询),动态问题被巧妙地转化为了静态问题。
若处理跨区贡献的复杂度为 \(O(N\log^k N)\) ,则总复杂度为 \(O(N\log^{k + 1}N)\)
3.1. 标准流程与偏序问题
实例分析:P3810【模板】三维偏序 (陌上花开)
题意概括:有 \(n\) 个元素,每个元素有三维坐标 \((a, b, c)\) 。对每个元素 \(i\) ,求有多少个元素 \(j\) 满足 \(a_j < = a_i\) , \(b_j < = b_i\) , \(c_j < = c_i\) 且 \(j! = i\) 。
分析:这是一个典型的三维偏序计数问题。
-
第一维(排序):首先对所有元素按第一维 a 为主要关键字,b 为次要关键字,c 为第三关键字进行排序。这样,对于任何元素 i,所有可能对它产生贡献的 j 都排在它前面。这一步将三维问题降为“在 i 之前的所有元素中,寻找满足 \(b_j <= b_i\) 且 \(c_j <= c_i\) 的元素个数”的动态二维偏序问题。注意,为了处理坐标完全相同的点,需要先去重,并记录每个点的重复次数。
-
第二维 (CDQ分治) : 我们将排序后的操作序列 \([1, n]\) 进行CDQ分治。考虑分治区间 \([1, r]\) , 其中点为 mid。
- 递归处理 solve(1, mid) 和 solve(mid+1, r)。
计算 [l, mid] 对 [mid+1, r] 的贡献。此时,对于 i \in [l, mid] 和 j \in [mid+1, r],已经天然满足 \(a_i <= a_j\)。问题转化为:对于每个 j,计算 [l, mid] 中有多少个 i 满足 \(b_i <= b_j\) 和 \(c_i <= c_j\)。 - 这是一个静态的二维偏序问题。我们可以将 [l, mid] 中的元素视为“修改”(加点),[mid+1, r] 中的元素视为“查询”。
- 第三维(数据结构):为了解决上述静态二维问题,我们将 [l, mid] 和 [mid+1, r] 中的元素统一按 b 坐标排序。然后用双指针扫描:指针 p 遍历 [l, mid],指针 q 遍历 [mid+1, r]。
当处理查询 \(q\) 时,我们将所有 \(b\) 坐标小于等于 \(b_q\) 的修改 \(p\) 都应用掉。具体地,就是在一个以 \(c\) 坐标为下标的数据结构(如树状数组或Fenwick Tree)中,在 \(c_p\) 位置上加1。
- 然后,对于查询 q,我们在树状数组中查询 [1, c_q] 的前缀和,这就是 [1, mid] 对 q 的贡献。
• 为了保证效率,[1, mid] 和 [mid+1, r] 内部按 b 排序的过程,可以利用归并排序的思想,在分治回溯时完成,避免了每次 O(N \log N) 的排序开销。
代码实现细节:
- 去重:先对所有元素排序去重,用一个 cnt 字段记录每个独特元素的出现次数。
\(\circ\) 分治函数 \(\operatorname{cdq}(1, r)\) :
\(\circ\) 基线条件 \(1 = = r\) 时返回。
cdq(1, mid), cdq(mid+1, r)。
。双指针p从1到mid,q从mid+1到r。维护一个临时数组,按b坐标归并[1,mid]和 [mid+1,r]。
在归并过程中,若取左侧元素 \(p\) ,则将其视为修改,更新树状数组:add(c_p, cnt_p)。
- 若取右侧元素 q,则将其视为查询,累加答案:ans_q += query(c_q)。
- 处理完跨区贡献后,必须清空树状数组中本次修改造成的影响,以便不影响其它分治分支。
#include<bits/stdc++.h>
using namespacestd;
using ll = long long;
const int N = 100005, K = 200005;
struct Ele {
int a, b, c, cnt, ans; // cnt: 重复次数, ans: 答案
} p[N], tmp[N];
int n, k;
int tr[K]; // 树状数组
int res[N];
bool cmp_a(const Ele& x, const Ele& y) {
if (x.a != y.a) return x.a < y.a;
if (x.b != y.b) return x.b < y.b;
return x.c < y.c;
}
bool cmp_b(const Ele& x, const Ele& y) {
if (x.b != y.b) return x.b < y.b;
return x.c < y.c;
}
void add(int x, int v) {
for (; x < K; x += x & -x) tr[x] += v;
}
int qry(int x) {
int sum = 0;
for (; x; x -= x & -x) sum += tr[x];
return sum;
}
void cdq(int l, int r) {
if (l >= r) return;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
// 按 b 排序并计算贡献
sort(p + l, p + mid + 1, cmp_b);
sort(p + mid + 1, p + r + 1, cmp_b);
int j = l;
for (int i = mid + 1; i <= r; i++) {
while (j <= mid && p[j].b <= p[i].b) {
add(p[j].c, p[j].cnt);
j++;
}
p[i].ans += qry(p[i].c);
}
// 清空树状数组
for (int i = l; i < j; i++) {
add(p[i].c, -p[i].cnt);
}
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
int m;
cin >> m >> k;
for (int i = 0; i < m; i++) cin >> p[i].a >> p[i].b >> p[i].c;
sort(p, p + m, cmp_a);
// 去重
n = 0;
for (int i = 0; i < m; ) {
int j = i;
while (j < m && p[j].a == p[i].a && p[j].b == p[i].b && p[j].c == p[i].c) {
j++;
}
tmp[n] = p[i];
tmp[n].cnt = j - i;
n++;
i = j;
}
for(int i=0; i<n; ++i) p[i] = tmp[i];
cdq(0, n - 1);
for (int i = 0; i < n; i++) {
res[p[i].ans + p[i].cnt - 1] += p[i].cnt;
}
for (int i = 0; i < m; i++) {
cout << res[i] << "\n";
}
return 0;
}
时间复杂度: \(O(m\log m + n\log n\log k)\) 。 \(m\log m\) 来自初始排序和去重。 \(n\log n\log k\) 是CDQ分治的复杂度,其中一层分治是 \(O(n\log k)\) (排序+树状数组),共 \(\log n\) 层。若使用归并优化排序,则为 \(O(n\log n + n\log n\log k)\) ,渐进意义下相同。
。空间复杂度: \(O(n + k)\) , 用于存储元素和树状数组。
3.2.CDQ分治与动态规划优化
CDQ分治不仅能处理偏序问题,还能用于优化一类DP方程。当DP的转移具有类似偏序的结构,即 dp[i] 由某个区间 [1, i-1] 内的 dp[j] 转移而来时,可以尝试使用CDQ分治加速转移。
与标准CDQ的区别:在标准CDQ中,左右区间 [1, mid] 和 [mid+1, r] 是相互独立的,可以任意序递归。但在DP中,dp[i] 的值依赖于 dp[j](j<i)的最终计算结果。这意味着,在计算 [1, mid] 对 [mid+1, r] 的贡献之前,[1, mid] 内部的所有DP值必须已经计算完毕。这要求一个中序遍历式的分治流程。这与标准CDQ分治的递归顺序有本质不同:标准CDQ的子问题相互独立,其递归过程等价于后序遍历(可以先递归右子树再递归左子树),而DP优化版本则必须遵循 左子树 -> 跨区贡献 -> 右子树 的中序遍历顺序,以保证转移的依赖关系正确。
\(\circ\) 算法流程(DP优化版):solve(1, r)
- solve(1, mid):递归计算左半边的所有DP值。
- ProcessCross(1, mid, r):用 [1, mid] 中已经算好的DP值,去更新 [mid+1, r] 中元素的DP值。
- solve(mid+1, r):递归计算右半边的所有DP值。此时,右半边已经获得了所有来自左边的贡献。
4. 整体二分
整体二分(Overall Binary Search)是一种将多个具有单调性的查询一并处理的离线算法。当单个查询的答案可以通过二分查找确定,但每次二分 check 的代价高昂时,整体二分通过批量处理所有查询的同一次 check,来摊平计算代价。
\(\mathrm{O}\) 适用场景:
- 问题离线。
- 查询的答案具有单调性,可以二分。
- 修改和查询可以被分离,并且修改对查询 check 的贡献可以高效地计算。
核心思想:传统的做法是“对每个查询,二分其答案”。整体二分颠覆了这个过程,变为“对答案的值域,二分一个mid,然后根据mid将所有查询分为两类:答案小于等于mid的和答案大于mid的”。通过这种方式,所有查询共享了同一次对mid的check过程,从而实现了效率的提升。
- 算法流程:定义一个递归函数 solve(q_l, q_r, ans_l, ans_r),表示我们正在处理下标在 [q_l, q_r] 内的查询,且已知它们的答案范围在 [ans_l, ans_r] 内。
如果 check 通过(例如,对于“第 k 小”问题,小于等于 mid 的元素个数大于等于 k),说明 q_i 的真实答案在 [ans_1, mid] 区间内。将其划分到“左”组。
- 否则,q_i 的答案在 [mid+1, ans_r] 区间内。将其划分到“右”组。对于“第 k 小”问题,需要将查询目标调整为 k - c,其中 c 是小于等于 mid 的元素个数。
- 若 ans_l == ans_r,则 [q_l, q_r] 内所有查询的答案都为 ans_l,赋值并返回。
2.取答案中点mid \(=\) (ans_1+ans_r)>>1。 - 将所有与当前 solve 相关的影响(如修改操作)分为两部分:效果值小于等于 mid 的和大于 mid 的。
- 应用所有效果值小于等于 mid 的修改,构建数据结构。
5.遍历[q_1,q_r]内的每个查询q_i,利用当前数据结构计算其check(mid)的结果。 - 将查询数组重排,使得左组查询在前,右组在后。设分界点为 m。
- 递归调用 solve(q_1, m, ans_1, mid) 和 solve(m+1, q_r, mid+1, ans_r)。
- 关键:在分治过程中,修改操作也应随查询一同被划分到左右子问题,以保证后续 check 的正确性。
实例分析:P1527 [国家集训队]矩阵乘法
题意概括:给定一个 \(\mathsf{N}^{*}\mathsf{N}\) 的矩阵,Q次询问一个子矩形的第k小数。
分析:这是典型的静态二维k小数问题。
• 单调性:对于任一查询,答案具有明显的单调性。我们可以二分答案 \(\mathrm{v}\) ,检查子矩形内小于等于 \(\mathrm{v}\) 的数的个数 \(\mathrm{C}\) 。若 \(\mathrm{C} > = \mathrm{k}\) ,则真实答案 \(< = \mathrm{v}\) ;否则真实答案 \(> \mathrm{v}\) 。
整体二分应用:
- 将矩阵中的 \(N^{*}N\) 个点视为“修改”操作(在 \((x, y)\) 位置增加一个值为 val 的点),Q 个询问视为“查询”操作。
- solve(querys, ans_1, ans_r) 处理当前查询集,答案范围为 [ans_1, ans_r]。
- mid = (ans_1 + ans_r) >> 1。
- 将所有矩阵中值小于等于 mid 的点加入一个二维数据结构(例如二维树状数组,或用一维树状数组+扫描线模拟)。
- 对每个查询,计算其矩形内点的个数 \(C\) 。
- 根据 c 和 k 的大小关系,将查询和相关的点(这是关键,点也需要被划分)分到左右两个子问题中。
- 递归。
为了避免每次都重建数据结构,更高效的做法是:在分治时,将操作(包括点和查询)一同传递。solve(op_l, op_r, ans_l, ans_r):
- mid = ...
2.遍历[op_1,op_r],将操作分为三类:
- 类型为“点”,且值 \(<=\) mid:视为修改,应用到数据结构上。
○ 类型为“点”,且值 > mid:暂时忽略。
○ 类型为“查询”:用当前数据结构计算C,并判断。
- 把“值 \(<=\) mid 的点”和“答案 \(<=\) mid 的查询”放入一个临时数组,剩下的放入另一个。
- 将原操作数组 [op_1, op_r] 用这两个临时数组覆盖。
- 递归。注意,进入右子问题 solve(..., mid+1, ans_r) 前,要撤销左子问题 solve(..., ans_l, mid) 对数据结构做的修改。
#include<bits/stdc++.h>
using namespacestd;
using ll = long long;
const int N = 505, Q = 60005;
struct Pt {int x, y, v; } p[N * N];
struct Qry {int x1, y1, x2, y2, k, id; } q[Q], ql[Q], qr[Q];
int n, m;
int tr[N][N];
int ans[Q];
void add(int x, int y, int v) {
for (int i = x; i <= n; i += i & -i)
for (int j = y; j <= n; j += j & -j)
tr[i][j] += v;
}
int qry(int x, int y) {
int s = 0;
for (int i = x; i; i -= i & -i)
for (int j = y; j; j -= j & -j)
s += tr[i][j];
return s;
}
int qry_r(int x1, int y1, int x2, int y2) {
return qry(x2, y2) - qry(x1 - 1, y2) - qry(x2, y1 - 1) + qry(x1 - 1, y1 - 1);
}
void solve(int l, int r, int q_l, int q_r) {
if (l > r || q_l > q_r) return;
if (l == r) {
for (int i = q_l; i <= q_r; i++) ans[q[i].id] = l;
return;
}
int mid = (l + r) >> 1;
int pl = 1;
while(pl <= n*n && p[pl].v <= mid) {
add(p[pl].x, p[pl].y, 1);
pl++;
}
int t_ql = 0, t_qr = 0;
for (int i = q_l; i <= q_r; i++) {
int cnt = qry_r(q[i].x1, q[i].y1, q[i].x2, q[i].y2);
if (cnt >= q[i].k) ql[++t_ql] = q[i];
else {
q[i].k -= cnt;
qr[++t_qr] = q[i];
}
}
for (int i = 1; i <= t_ql; i++) q[q_l + i - 1] = ql[i];
for (int i = 1; i <= t_qr; i++) q[q_l + t_ql + i - 1] = qr[i];
// 撤销修改
for(int i = 1; i < pl; ++i) add(p[i].x, p[i].y, -1);
solve(l, mid, q_l, q_l + t_ql - 1);
solve(mid + 1, r, q_l + t_ql, q_r);
}
// 优化版solve,将点和查询一起传递
void solve2(int l, int r, int p_l, int p_r, int q_l, int q_r) {
if (p_l > p_r || q_l > q_r) return;
if (l == r) {
for (int i = q_l; i <= q_r; i++) ans[q[i].id] = l;
return;
}
int mid = (l + r) >> 1;
int p_l1 = 0, p_r1 = 0; // 临时点数组
static Pt p_tmp1[N*N], p_tmp2[N*N];
int q_l1 = 0, q_r1 = 0; // 临时查询数组
for (int i = p_l; i <= p_r; i++) {
if (p[i].v <= mid) {
add(p[i].x, p[i].y, 1);
p_tmp1[++p_l1] = p[i];
} else {
p_tmp2[++p_r1] = p[i];
}
}
for (int i = q_l; i <= q_r; i++) {
int cnt = qry_r(q[i].x1, q[i].y1, q[i].x2, q[i].y2);
if (cnt >= q[i].k) {
ql[++q_l1] = q[i];
} else {
q[i].k -= cnt;
qr[++q_r1] = q[i];
}
}
for (int i = p_l; i <= p_r; i++) {
if(p[i].v <= mid) add(p[i].x, p[i].y, -1);
}
for(int i=1; i<=p_l1; ++i) p[p_l + i - 1] = p_tmp1[i];
for(int i=1; i<=p_r1; ++i) p[p_l + p_l1 + i - 1] = p_tmp2[i];
for(int i=1; i<=q_l1; ++i) q[q_l + i - 1] = ql[i];
for(int i=1; i<=q_r1; ++i) q[q_l + q_l1 + i - 1] = qr[i];
solve2(l, mid, p_l, p_l + p_l1 - 1, q_l, q_l + q_l1 - 1);
solve2(mid + 1, r, p_l + p_l1, p_r, q_l + q_l1, q_r);
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin >> n >> m;
int pc = 0;
int max_v = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
pc++;
p[pc].x = i; p[pc].y = j;
cin >> p[pc].v;
max_v = max(max_v, p[pc].v);
}
}
// sort(p + 1, p + pc + 1, [](Pt a, Pt b){ return a.v < b.v; });
for (int i = 1; i <= m; i++) {
cin >> q[i].x1 >> q[i].y1 >> q[i].x2 >> q[i].y2 >> q[i].k;
q[i].id = i;
}
// solve(0, max_v, 1, m); // 朴素版本
solve2(0, max_v, 1, pc, 1, m); // 优化版本
for (int i = 1; i <= m; i++) {
cout << ans[i] << "\n";
}
return 0;
}
时间复杂度: solve2 版本中, 每个操作 (点或查询) 在分治的每一层都会被处理一次, 共 \(\log V\) 层 ( \(V\) 是值域)。每层处理的代价主要是对数据结构的操作。如果内层用树状数组+扫描线替代二维树状数组, 每层复杂度是 \(O((N^{2} + Q) \log N)\) 。总复杂度为 \(O((N^{2} + Q) \log V \log N)\) 。此处的 \(O(\log N)\) 来源于内层实现。若直接采用二维树状数组, 则单次查询为 \(O(\log^{2} N)\) , 总复杂度会相应增加一个 \(\log N\) 因子。
- 空间复杂度: \(O(N^{2} + Q)\) , 用于存储元素和查询。
5. 线段树分治
线段树分治是另一种重要的离线处理技巧。它与CDQ分治的适用场景有所不同:CDQ处理的是时间点上的修改,而线段树分治处理的是在时间段上生效的修改。
适用场景:
- 问题离线。
- 操作(或对象)的生命周期是一个时间区间 [t_start, t_end]。
- 数据结构支持可撤销的修改。均摊复杂度的数据结构(如带路径压缩的并查集)通常不适用,因为其复杂度依赖于连续的操作序列,而分治过程会打断这种连续性。
核心思想:
- 以时间轴 [1, T] 为范围建立一棵线段树。
- 对于每一个生命周期为 [t_start, t_end] 的操作,将其“挂”在线段树上代表该区间的 \(O(\log T)\) 个节点上。
3.对线段树进行一次深度优先遍历(DFS)。 - 当进入一个节点 u 时,执行所有挂在该节点上的修改。
- 递归访问其子节点。
- 从节点 u 回溯时,撤销所有在 u 处执行的修改,恢复进入 u 之前的状态。
- 当DFS到达叶子节点 \(t\) 时, 数据结构的状态即为时间点 \(t\) 的真实状态, 此时可以回答所有在时间 \(t\) 的查询。
实例分析:P5787 二分图 / 【模板】线段树分治
题意概括:给定 \(m\) 条边,每条边有一个存在的时间区间 [1, r]。在 \(k\) 个时间点上,分别询问当时的图是否为二分图。
分析:一个图是二分图的充要条件是不存在奇环。我们可以使用带权并查集来维护点之间的路径异或和,从而判断是否形成奇环。
• 数据结构:按秩合并(或按大小合并)的并查集。同时维护一个 \(d[i]\) 数组,表示节点 \(i\) 到其所在集合根节点的路径上边权的异或和。合并 \(u, v\) 时,若它们已在同一集合,检查 \(d[u] \wedge d[v]\) 是否等于边权 \(w\) ,不等则形成奇环。若不在同一集合,合并 find(u) 到 find(v),并更新 \(d[find(u)]\) 。
- 可撤销性:路径压缩会改变树的结构,难以撤销。而按秩合并只会改变父指针和秩,我们可以用一个栈记录每次合并前的父指针和秩的值,回溯时从栈中弹出并恢复即可。
线段树分治应用:
- 建立一棵覆盖时间 \([1, k]\) 的线段树。
- 对于每条边 \(\{\mathrm{u},\mathrm{v}\}\) ,其存在区间为[1,r],将这条边(的操作)插入到线段树上对应[1,r]的节点中。
- DFS线段树。在每个节点,用一个栈记录当前节点进行的并查集合并操作。
- 进入节点,遍历该节点挂载的所有边,用带权并查集尝试合并。若在合并过程中发现奇环,则该节点所代表的整个时间区间内图都不是二分图。
- 若未发现奇环,则递归进入子节点。
- 从子节点返回后,根据栈中记录,撤销所有在本节点进行的合并操作。
代码实现细节:
\(\circ\) 并查集需要维护fa,sz(或rnk)和d(距离)。
- 用一个 stack<int*, int>> 来记录修改。stk.push({&fa[x], fa[x]}) 记录 fa[x] 的旧值。回溯时 *stk.top().first) =stk.top().second;stk.pop(); 即可恢复。
DFS函数 solve(u, l, r) 需要传递当前图中奇环的状态。
#include<bits/stdc++.h>
usingnamespacestd;
using ll = longlong;
const int N = 100005, M = 200005;
struct Edge {int u, v; };
vector<Edge> tree[4 * N];
int fa[N], sz[N], d[N]; // fa:父节点, sz:大小, d:到根的异或和
stack<pair<int, int>> stk; // 栈, 用于撤销
int fnd(int x) {
while (x != fa[x]) x = fa[x];
return x;
}
int get_d(int x) {
int res = 0;
while (x != fa[x]) {
res ^= d[x];
x = fa[x];
}
return res;
}
void add(int u, int l, int r, int L, int R, Edge e) {
if (L <= l && r <= R) {
tree[u].push_back(e);
return;
}
int mid = (l + r) >> 1;
if (L <= mid) add(u << 1, l, mid, L, R, e);
if (R > mid) add(u << 1 | 1, mid + 1, r, L, R, e);
}
void solve(int u, int l, int r, bool ok) {
int top = stk.size();
if (ok) {
for (auto& e : tree[u]) {
int ru = fnd(e.u), rv = fnd(e.v);
if (ru != rv) {
// 按大小合并
if (sz[ru] > sz[rv]) swap(ru, rv);
stk.push({ru, fa[ru]}); fa[ru] = rv;
stk.push({-rv, sz[rv]}); sz[rv] += sz[ru];
stk.push({ru + N, d[ru]}); d[ru] = get_d(e.u) ^ get_d(e.v) ^ 1;
} else {
if ((get_d(e.u) ^ get_d(e.v)) == 0) {
ok = false;
break;
}
}
}
}
if (l == r) {
cout << (ok ? "Yes\n" : "No\n");
} else {
int mid = (l + r) >> 1;
solve(u << 1, l, mid, ok);
solve(u << 1 | 1, mid + 1, r, ok);
}
// 撤销操作
while (stk.size() > top) {
auto p = stk.top(); stk.pop();
if (p.first > 0) { // 恢复 fa 和 d
if (p.first > N) d[p.first - N] = p.second;
else fa[p.first] = p.second;
} else { // 恢复 sz
sz[-p.first] = p.second;
}
}
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
int n, m, k;
cin >> n >> m >> k;
map<pair<int, int>, int> mp;
for (int i = 1; i <= m; i++) {
int u, v, l, r;
cin >> u >> v >> l >> r;
if (u > v) swap(u, v);
// 区间加边,可以用map记录边的出现时间点
add(1, 1, k, l + 1, r, {u, v});
}
for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1, d[i] = 0;
solve(1, 1, k, true);
return 0;
}
• 时间复杂度: \(O(m \log k + k \log n)\) 。 \(m \log k\) 是将 \(m\) 条边的生命周期加入线段树的开销。 \(k \log n\) 是 DFS 的总开销,每个叶子节点(共 \(k\) 个)的路径长为 \(O(\log k)\) ,每个节点上的并查集操作是 \(O(\log n)\) 。
- 空间复杂度: \(O(n + m \log k)\) , 主要用于并查集和线段树中存储的操作。
6. 序列分治 (猫树分治)
序列分治, 有时被昵称为 “猫树分治”, 是一种处理静态序列区间查询的离线分治技巧。其思想根源于图论中的点分治, 可以看作是在序列下标构成的链上进行点分治。
\(\mathrm{O}\) 适用场景:
1.问题是静态的、关于序列上的区间查询。
- 查询的答案具有可合并性。具体而言,对于一个跨越了分治中心 mid 的查询 [L, R],其答案可以由 [L, mid] 的信息和 [mid+1, R] 的信息快速合并得到。
• 核心思想:对序列下标区间 \([1, r]\) 进行分治。取中点 mid。预处理出所有以 mid 为一端点的区间的“半边信息”(例如,从 mid 到 l 的后缀信息,从 mid+1 到 r 的前缀信息)。然后,对于所有完整跨越 mid 的查询 \([L, R]\) (即 \(L <= \text{mid} < R\) ),可以直接利用预处理好的信息 info(L, mid) 和 info(mid+1, R) 合并出答案。对于完全落在 [l, mid] 或 [mid+1, r] 的查询,则递归到相应的子问题中处理。
- 算法流程:定义一个递归函数 solve(1, r, queries),其中 queries 是所有区间完全包含于 [1, r] 的查询。
如果需要在线查询,可以将分治过程中每层的 mid 和预处理信息存储下来,构成一棵“猫树”,查询时找到查询区间对应的最高层分治中心即可。
\(\circ\) solve(1, mid - 1, left_q)
\(\circ\) solve(mid + 1, r, right_q)
- 将 queries 分为三部分:跨越 mid 的 cross_q,落在左侧的 left_q,落在右侧的 right_q。
- 对于 cross_q 中的每个查询 [L, R],用预处理的信息合并出答案。
从 mid 向右扫描到 r,计算并存储每个 i 对应区间 [mid, i] 的信息。
从mid向左扫描到1,计算并存储每个i对应区间[i,mid]的信息。
1.如果 \(1 > r\) 或 queries为空,返回。
- 取中点 \(\mathrm{mid} = (1 + r) >> 1\) 。
- 预处理:
- 处理跨界查询:
- 递归:
实例分析:CF1100F Ivan and Burgers
题意概括:静态序列, \(\mathrm{Q}\) 次查询,每次询问区间 [1,r] 内子序列的最大异或和。
分析:
• 子问题: 区间最大异或和需要用到线性基。合并两个线性基的复杂度是 \(O(D \wedge 2)\) , 其中 D 是基的大小 (在此题中 \(D \Leftarrow 20\) )。
• 可合并性: 区间 \([L, R]\) 的最大异或和, 可以通过合并 \([L, \text{mid}]\) 的线性基和 \([mid + 1, R]\) 的线性基来得到。
- 序列分治应用:
\(\circ\) basis_R[mid]为{a[mid]}的基。basis_R[i] \(=\) merge(basis_R[i-1], a
[i]) for i from mid+1 to r。
\(\circ\) basis_L[mid]为{a[mid]}的基。basis_L[i] \(=\) merge(basis_L[i+1], a
[i]) for i from mid-1 down to 1.
- solve(1, r, queries)。取 mid = (l+r) >> 1。
2.预处理: - 处理跨界查询 [L, R]:合并 basis_L[L] 和 basis_R[R],然后在新基上查询最大值。
- 递归处理完全落在某一侧的查询。
#include<bits/stdc++.h>
using namespacestd;
using ll = long long;
const int N = 500005, D = 20;
int n, q;
int a[N];
struct Qry {int l, r, id; };
vector<Qry> qs;
int ans[N];
struct Basis {
int b[D];
Basis() { memset(b, 0, sizeof(b)); }
void ins(int x) {
for (int i = D - 1; i >= 0; i--) {
if (!(x >> i & 1)) continue;
if (!b[i]) { b[i] = x; return; }
x ^= b[i];
}
}
int qry_max() {
int res = 0;
for (int i = D - 1; i >= 0; i--) {
res = max(res, res ^ b[i]);
}
return res;
}
};
Basis mrg(Basis p, Basis q) {
for (int i = D - 1; i >= 0; i--) {
if (q.b[i]) p.ins(q.b[i]);
}
return p;
}
void solve(int l, int r, vector<Qry>& cur_q) {
if (l > r || cur_q.empty()) return;
int mid = (l + r) >> 1;
vector<Qry> ql, qr;
Basis pref[r - mid + 1], suff[mid - l + 1];
// 预处理右半部分的前缀基
pref[0].ins(a[mid]);
for(int i = mid + 1; i <= r; ++i) {
pref[i - mid] = pref[i - mid - 1];
pref[i - mid].ins(a[i]);
}
// 预处理左半部分的后缀基
suff[0].ins(a[mid]);
for(int i = mid - 1; i >= l; --i) {
suff[mid - i] = suff[mid - i - 1];
suff[mid - i].ins(a[i]);
}
for (auto& cq : cur_q) {
if (cq.r < mid) ql.push_back(cq);
elseif (cq.l > mid) qr.push_back(cq);
else {
Basis res = mrg(suff[mid - cq.l], pref[cq.r - mid]);
ans[cq.id] = res.qry_max();
}
}
solve(l, mid - 1, ql);
solve(mid + 1, r, qr);
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
cin >> q;
for (int i = 0; i < q; i++) {
int l, r;
cin >> l >> r;
qs.push_back({l, r, i});
}
solve(1, n, qs);
for (int i = 0; i < q; i++) {
cout << ans[i] << "\n";
}
return 0;
}
• 时间复杂度: 在一层分治 solve(1, r, ...) 中, 预处理的代价是 \(O((r - 1)^* D)\) 。处理跨界查询的代价是 \(O(|\text{cross\_q}| * D^{\wedge}2)\) 。由于每个查询只会在一个分治节点被作为跨界查询处理一次, 总的查询处理代价是 \(O(Q * D^{\wedge}2)\) 。预处理的总代价是 \(O(N \log N * D)\) 。所以总复杂度为 \(O(N \log N * D + Q * D^{\wedge}2)\) 。
• 空间复杂度: \(O(N + Q + N^{*}D)\) , 主要是存储输入和每层分治的预处理信息。空间可以优化到 \(O(N+Q)\) 。
7. 二进制分组
二进制分组是一种将动态问题(尤其是在线问题)转化为多个静态问题进行处理的技巧。它以一种优雅的方式平衡了单次操作的开销和查询的效率,是CDQ分治的在线对应物之一。
适用场景:
- 问题在线。
- 修改操作的贡献可以独立计算和合并。
- 在 k 个元素上构建一个静态数据结构的代价可以接受。
• 核心思想: 将数据流中的 \(\mathrm{N}\) 个修改操作, 按照二进制位分组。维护 \(\log \mathrm{N}\) 个 “块”, 第 \(\mathrm{i}\) 块 (如果存在) 包含 \(2^{\wedge} \mathrm{i}\) 个修改。当新来一个修改时, 它形成一个大小为 1 的新块。如果此时已经有一个大小为 1 的块, 则将它们合并成一个大小为 2 的新块。这个过程像二进制加法一样, 不断产生进位, 直到找到一个空位。一个查询的答案, 就是分别在所有存在的块上查询, 然后将 \(\mathrm{O}(\log \mathrm{N})\) 个结果合并。
- 算法流程:维护一个数据结构数组 DS[0..logN]。
添加操作:
查询操作:
1.遍历所有非空的DS[i]。
- 在每个 DS[i] 上执行查询。
- 将所有 \(O(\log N)\) 个查询结果合并。
- 将新元素构成一个大小为 1 的块。
- 从 \(i = 0\) 开始,如果 DS[i] 为空,则将新块放入 DS[i] 并停止。
- 如果 DS[i] 不为空, 则将新块与 DS[i] 中的块合并, 形成一个大小为 \(2^{\wedge}(\mathrm{i} + 1)\) 的新块。清空 DS[i], 并带着新块继续到 \(\mathrm{i} + 1\) 。
复杂度分析:一个元素一生中最多被合并 \(\log N\) 次(每次它所在的块大小翻倍)。如果合并两个大小为 \(k\) 的块的代价为 \(O(Merge(k))\) (通常与在 \(2k\) 个元素上建构造成本 Build(2k) 同阶),则 \(N\) 次插入的总均摊代价为 \(O(Build(N) \log N)\) 。查询的代价为 \(O(\text{Query}_\text{Static} * \log N)\) 。需要注意,如果单次静态查询 Query_Sstatic 本身是对数级的(如在静态线段树上查询),则总查询复杂度会是 \(O(\log^2 N)\) 。
在空间方面,由于一个元素在合并过程中会被复制,其在不同大小的块中可能同时存在多个副本,总空间复杂度可能达到 \(O\left(\text{总元素大小} \cdot \log N\right)\) 。
实例分析:CF710F String Set Queries
题意概括:动态维护一个字符串集合,支持添加、删除字符串,并查询集合中所有字符串在给定文本串 \(T\) 中的出现总次数。强制在线。
分析:
• 静态问题: 如果集合是固定的, 可以对集合中所有字符串建立一个AC自动机。预处理fail指针和fail树上的信息后, 将文本串 \(T\) 在自动机上匹配, 即可在 \(O(|T| + \text{TotalLen})\) 时间内得到答案。
。在线化:直接动态维护AC自动机很复杂。二进制分组提供了一个简洁的方案。
- 用两个二进制分组结构,一个 ADD 组用于处理添加,一个 DEL 组用于处理删除。
- 添加 s: 将 s 作为一个大小为 1 的块(一个只含 s 的 AC 自动机)。按照二进制分组的逻辑,与 ADD 组中已有的 AC 自动机合并。合并两个 AC 自动机,最简单的方法是抽取出所有字符串,然后重新建立一个新的 AC 自动机。
- **删除 s **:同理,在 DEL 组中添加 s。
- 查询 T: 遍历 ADD 组的所有 O(log N) 个AC自动机, 分别在上面匹配 T 并求和。再遍历 DEL 组, 同样求和。最终答案为 ADD_sum - DEL_sum。
#include <bits/stdc++.h>
using namespacestd;
using ll = long long;
const int MX = 300005;
struct DS {
// ch: trie树/AC自动机转移
// f: fail指针
// val: 节点代表的模式串出现次数(已沿fail链累加)
int ch[MX][26], f[MX], val[MX];
// stk: AC自动机根节点栈
// sz: 每个分组的大小
// fr: 每个分组包含的字符串在s数组中的起始下标
int stk[25], sz[25], fr[25];
// cnt: 节点总数
// gcn: 分组数
// scn: 字符串总数
int cnt, gcn, scn;
string s[MX];
DS() : cnt(0), gcn(0), scn(0) {} // 构造函数
void ins(int r, const string& t) { // trie插入
int cur = r;
for (char c : t) {
int p = c - 'a';
if (ch[cur][p] == r) { // 如果没有子节点,新建
ch[cur][p] = ++cnt;
val[cnt] = 0;
for (int i = 0; i < 26; ++i) ch[cnt][i] = r;
}
cur = ch[cur][p];
}
val[cur]++;
}
void bld(int r) { // 构建fail指针并累加val
queue<int> q;
f[r] = r;
for (int i = 0; i < 26; ++i) {
if (ch[r][i] != r) {
f[ch[r][i]] = r;
q.push(ch[r][i]);
}
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < 26; ++i) {
int v = ch[u][i];
if (v != r) {
f[v] = ch[f[u]][i];
val[v] += val[f[v]]; // 累加fail链上的匹配数
q.push(v);
} else {
ch[u][i] = ch[f[u]][i]; // 优化goto函数
}
}
}
}
void mrg() { // 合并最后两个大小相同的分组
gcn--;
sz[gcn] <<= 1;
int r = stk[gcn];
// 重用节点空间,重新构建AC自动机
cnt = r;
val[r] = 0;
for (int i = 0; i < 26; ++i) ch[r][i] = r;
for (int i = fr[gcn]; i <= scn; ++i) {
ins(r, s[i]);
}
bld(r);
}
void add(const string& t) { // 添加字符串,处理二进制分组
s[++scn] = t;
gcn++;
sz[gcn] = 1;
fr[gcn] = scn;
// 创建一个新的AC自动机
stk[gcn] = ++cnt;
int r = stk[gcn];
val[r] = 0;
for (int i = 0; i < 26; ++i) ch[r][i] = r;
ins(r, t);
bld(r);
while (gcn > 1 && sz[gcn] == sz[gcn - 1]) {
mrg();
}
}
ll qry(const string& t) { // 在所有分组的AC自动机上查询
ll ans = 0;
for (int i = 1; i <= gcn; ++i) {
int cur = stk[i];
for (char c : t) {
cur = ch[cur][c - 'a'];
ans += val[cur];
}
}
return ans;
}
};
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int m;
cin >> m;
DS add, del; // add处理添加,del处理删除
while (m--) {
int op;
string t;
cin >> op >> t;
if (op == 1) {
add.add(t);
} elseif (op == 2) {
del.add(t);
} else {
// 使用 endl 替代 "\n" 来确保输出被刷新
cout << add.qry(t) - del.qry(t) << endl;
}
}
return 0;
}
时间复杂度: 设总操作数为M,字符串总长为L。一次插入/删除,最坏情况下会触发log M次合并。一次合并k个字符串(总长S_k)的AC自动机,代价是O(S_k)。一个字符串在其生命周期中被合并O(log M)次。因此,插入M个字符串的总代价是O(L log M)。查询一个长度为T的文本串,需要在O(log M)个AC自动机上运行,代价是O(T log M)。
空间复杂度: O(L),因为每个字符串只会被存储在一个地方(或者在合并的缓冲区中临时存在)。
8. 根号算法:平衡暴力与优雅
根号算法是一类通过将问题规模 \(N\) 分解为若干个大小约为 √N 的块,从而在单次操作的复杂度能操作次数之间取得精妙平衡的技巧。它们通常实现简单,常数较小,是解决许多在线或离线区间问题的有力武器。
8.1.分块:通用序列划分
分块,或称“优雅的暴力”,其核心思想是将一个长为N的序列划分为√N个长度为√N的块。对于一个区间操作,它会被拆分为对若干个完整块的整体操作和对两端不完整块的暴力操作。
适用场景:在处理收到的访问请求与修改,特别是用于一些难以用代码和指标进行传递的操作。如问
开方、区间查询众数等。
心法:把序列切成块,块内暴力、块间隙标记。
复杂度:单次操作通常为O(√N)。对整块的操作是O(1)(通过懒标记),对散块的暴力操作是O(√N)。
| 维度 | 分块(基本/复杂) |
| 是否离线 | 在线 |
| 适用结构 | 连续区间或固定块内操作(典型:区间加、求和、众数、布尔卷积) |
| 复杂度 | 均推O(√N)-O(√N log N)每次 |
经验之谈:
\(\circ\) 块长B = √N是理论上的最优近似。但在实践中,当块内或块间操作带有log因子时,需要根据具体复杂度表达式来调整块长B,例如平衡B和N/B * log N。
\(\circ\) 分块的灵活性是其最大优点。可以在块上维护各种复杂信息(如排序后的块、哈希表、甚至另一棵数据结构),从而解决线段树难以处理的问题。
8.2. 莫队算法:离线区间查询的艺术
莫队算法(Mo's Algorithm)是一种专门处理离线区向查询问题的巧妙算法。它通过对查询进行重排序,使得处理时能查出实时、维护的区间 \([1, r]\) 的两个端点 \(r_1\) 和 \(r_2\) 移动的总距离最小化,从而摊平了每次查询的计算成本。
\(\circ\) 适用场景:离线、无修改(或少量修改)的区间查询。要求从[l, r]的答案能通过O(1)或O(log N)的代价,快速推导出[l-1, r], [l+1, r], [l, r-1], [l, r+1]的答案。
\(\circ\) 心法:排序重排查询,使指针移动最少,维护增删贡献。
静态莫队:
1.将序列分块,块长为B = N/√Q。
2.对所有查询[L,R],按照L所在块的编号为第一关键字,R为第二关键字进行排序。
- 维护当前空间 \([1, r]\) 和答案。按排序后的顺序处理查询,通过移动 \(1\) , \(r\) 指针来匹配目标查询区间,并在移动过程中更新答案。
带修改莫队:当问题包含单点修改时,莫队算法可以增加一个“时间”维度。
1.将查询L,R]附加上它发生前已经历的修改次数T。
2.对查询(L,R,T)排序,关键字为([L/B],[R/B],T)。块长B通常取 \(N^{*}(2/3)\) 。
3.维护三个指针l, r, t,分别表示当前区间和时间。处理查询时,移动这三个指针,应用或撤销修改。
| 维度 | 莫队(静态/待修改) |
| 是否离线 | 离线 |
| 适用结构 | 连续区间 查询:贡献可删除,满足 “邻区间只差一个点” |
| 复杂度 | 静态: \( O\left( {\left( {N + Q}\right) \sqrt{N}}\right) \) 带修改: \( O\left( {{N}^{2/3}\text{ (标准 ) } \cdot O\left( {Q{N}^{2/3}}\right) \left( {8 - N \sim \left( {2/3}\right) }\right) }\right) \) |
经验之谈:
\(\circ\) 莫队算法要求贡献的增删是可逆的。例如,出现次数、异或和、集合大小等。但它不能维护“区间最小值”这类信息,因为删除一个非最小值元素后,无法快速知道新的最小值是什么。
\(\circ\) 排序时的奇偶块优化:如果L所在的块编号是奇数,则R升序排;如果是偶数,则R降序排。这可以减少R指针的大幅度往返移动,是重要的常数优化。
9. 可持久化:将“历史”作为维度
可持久化技术允许我们访问和操作数据结构的任意历史版本,相当于为数据结构增加了一个“时间”维度。它不是凭空复制整个结构,而是通过巧妙地共享未改变的部分,以较小的时空代价实现版本控制。
适用场景:
\(\circ\) 查询必须追溯到某个旧版本(时间旅行)。
\(\circ\) 需要同时存在多个分支版本进行对比。
\(\circ\) 操作是局部修改,不会牵动整个结构(路径复制的成本可容忍)。
9.1.主席树:可可持久化线段树的典范
主席树,严格来说是可持久化权值线段树,是可持久化最经典的应用。它通过对序列的每个前缀[1, i]建立一棵权值线段树,并利用可持久化技术,使得版本i的树与版本i-1的树共享大量节点。
核心思想:每次修改(通常是插入一个新元素),只新建从根到被修改叶子路径上的O(log V)个节点,其余子树直接指向旧版本的对应节点。
典型应用:静态区间第k小。查询区间[L, R]的第k小,等价于在前缀R对应的线段树和前缀L-1对应的线段树上作差,然后在这棵“差分树”上进行类似平衡树的k小查询。
| 数据结构 | 技巧 | 典型复杂度 |
| 可持久化线段树/主席树 | 路径复制 | 建树 O(Nlog V), 单次查询 O(log V) |
| 可持久化并查集 | 路径复制 + 按秩/大小合并 | 修改/查询 O(log N) (回溯到某版本) |
| 函数式栈链表 | 纯结构共享,头指针换代 | O(1) |
9.2.小结
\(\circ\) 对于“第k小/大”、“历史版本和”等问题,主席树结合前缀和思想,几乎是模板级的神器。
\(\circ\) 可持久化并非万能。如果只需要访问“最近k个版本”,使用差分思想配合环形缓冲区,通常比完全可持久化有更好的常数表现。
10. 树上路径维护:剖分与动态化
树上的路径/子树问题是竞赛中的高频考点。将树结构转化为线性结构,是解决这类问题的通用范式。
10.1. 重链剖分 (HLD)
重链剖分是一种静态树处理技术,它将树拆分成O(log N)条不相交的重链,使得任意一条树上路径可以被分解为O(log N)段连续的链上区间。然后,可以用线段树、树状数组等序列数据结构来维护这些链。
\(\circ\) 亮点:实现相对简单,常数低,适用范围广(路径/子树的加、求和、最值等)。
\(\circ\) 局限:对于动态改变树结构的操作(连边、断边)支持不佳。
10.2. Link-Cut Tree (LCT)
LCT是一种强大的动态树数据结构,它用一系列Splay树来表示原树的重链剖分(实链剖分),并支持高效的动态连边、断边、换根等操作。
\(\circ\) 亮点:真正的“动态树”,支持在O(log N)均摊时间内进行路径查询/修改、子树信息维护(需要技巧)、以及改变树的拓扑结构。
\(\circ\) 痛点:常数巨大,代码实现复杂,调试困难,被戏称为“调试杀手”。
| 技巧 | 主要场景 | 亮点与坑 |
| 重链剖分(HLD) | 静态树:路径/子树加、求和、最大值等 | 将树换成O(logN)条链→结段树/BI维护,常数低,实现友好。 |
| Link-Cut Tree (LCT) | 树结构动态连边、断边、维护行/子树聚合 | 支持真实“动态树”,单次O(logN),常数大,调试杀手。 |
| Euler Tour Tree | 需要非二分制、全局连接度信息(如动态图连通性) | 操作可撤销,搭配线段树分治→动态图连通性;编码复杂度高。 |
选型建议:
\(\circ\) 赛事中若问题为静态树,或仅涉及少量修改,优先考虑HLD。
\(\circ\) 当问题核心在于“频繁的Cut-Link操作”与“路径信息聚合”时,如动态维护最小生成树,LCT是必然之选。
\(\circ\) 所有动态树结构都基于可撤销的底层操作,因此要警惕与路径压缩这类破坏可撤销性的优化同时使用,尤其是在分治框架下。
11. Segment Tree Beats: 高级区间操作框架
传统的线段树懒标记机制在面对一些非线性或带条件的操作时会失效。例如,对区间[L, R]中的每个a[i],更新为min(a[i], x)。Segment Tree Beats(吉司机线段树)为这类问题提供了一个优雅的解决方案。
核心思想:通过在每个节点维护更多的信息(如区间最大值、次大值、最大值个数等),来快速判断一个修改操作是否会对该节点代表的区间产生“平凡”或“非平凡”的影响。
平凡影响:例如,当x大于等于区间最大值时,min(a[i], x)操作对整个区间无效。
次平凡影响:当x介于区间次大值和最大值之间时,只有最大值会被更新。
复杂影响:当x小于区间次大值时,修改会影响多种数值,无法打懒标记,只能暴力递归到子节点。
效果:通过势能分析可以证明,对于区间取min/max、区间加、查询区间和/最值等一系列操作,其均摊复杂度仍为O(log N)。需要强调的是,该势能分析的结论高度依赖于操作集合的封闭性。若与其它类型的懒标记混合使用,其均摊复杂度不一定能得到保证。
扩展:Beats的思想可以进一步扩展,用于维护带历史最值的查询。只需在节点中增加“历史最大/小值”和相应的懒标记,维护逻辑类似。
12. 终章:如何选择你的武器?
面对一个复杂的数据结构问题,如何从这个庞大的工具箱中选择最合适的“武器”?
12.1.快速索引:我该选哪一招?
下表根据问题的核心约束,提供了一个决策路径,帮助您快速定位可能适用的高级维护技巧。
| 主条件 | 次要条件 | 细分条件/典型场景 | 推荐技巧 |
| 离线 | 查询可统一排序 | 答案具有单调性,可对值域二分 | 整体二分 / 莫队 |
| 查询针对连续区间 | 贡献可快速缩减一个单位 | 莫队算法 | |
| 操作在时间段上生效 | 操作具有可(栈式)撤销性 | 线段树分治 | |
| 操作在时间点上生效 | 贡献可独立分解,能转化为静态子问题 | CDQ 分治 | |
| 在线 | 操作可(栈式)撤销 | 贡献可合并 | 二进制分组 |
| 树成图结构动态变化 | 频繁的连边、断边操作 | Link-Cut Tree (LCT)/ Euler Tour Tree | |
| 静态结构,查询连续区间 | 操作复杂,难以用标准懒标记维护 | 分块 / 树套树 | |
| 其他复杂场景 | 涉及历史版本查询 | 可持续化数据结构 |
12.2. 结尾三法则
- 判别顺序法则:
\(\circ\) 见到区间+离线,优先思考:能否对查询重排序(莫队)?答案能否二分(整体二分)?
\(\circ\) 见到高维+统计,优先思考:贡献是否可分解?能否通过分治将动态问题静态化(CDQ)?
2.栈式可撤销不能于随意撤销
在回溯式分治(如线段树分治)中,并查集必须使用按秩/大小合并,放弃路径压缩。若确实需要在离线场景下结合路径压缩,可以考虑按时间分块后离线重建等策略,但这已不属于线段树分治的范畴。
3.常数与内存的权衡:
\(\circ\) 理论复杂度相近时,实现复杂度是重要考量。HLD比LCT好写,分块比树套树好写。
\(\circ\) 线段树分治、整体二分这类算法虽然代码行数多,但常数通常优于复杂的在线结构。LCT虽然强大,但其巨大的常数可能在特定数据范围下被卡。选择时,务必结合时限和内存限制。

浙公网安备 33010602011771号