树状数组进阶:在线与离线操作实战,解锁区间统计新姿势
讲解树状数组进阶中的在线与离线操作思想。通过 HH 的项链和采花两道经典例题,演示如何利用离线排序结合树状数组解决区间不同元素个数及出现次数统计问题。核心在于将复杂查询转化为单点修改加区间查询模型,优化时间复杂度至 O((n+m)logn)。适合算法竞赛及笔试中处理复杂区间统计场景。

讲解树状数组进阶中的在线与离线操作思想。通过 HH 的项链和采花两道经典例题,演示如何利用离线排序结合树状数组解决区间不同元素个数及出现次数统计问题。核心在于将复杂查询转化为单点修改加区间查询模型,优化时间复杂度至 O((n+m)logn)。适合算法竞赛及笔试中处理复杂区间统计场景。

在树状数组的学习中,我们已经掌握了一维、二维的各类基础操作(单点修改、区间查询等),但面对复杂的区间统计问题(如'查询区间内不同元素的个数'),单纯的基础操作已经难以应对。这时候,在线操作与离线操作的思想就成了破局的关键。
在线与离线并非某种具体算法,而是两种截然不同的解题思路——在线操作要求'即时响应查询',离线操作则允许'先收集所有操作,再重新排序处理'。树状数组作为高效的区间统计工具,与这两种思想结合后,能轻松解决各类复杂场景问题。
本文将从'在线/离线的核心概念'入手,结合两道经典例题(HH 的项链、采花),深度解析树状数组在离线操作中的应用,带你掌握'离线排序 + 树状数组统计'的解题模板,让复杂区间问题迎刃而解!
在算法题中,所有涉及'修改'和'查询'的操作都可以按'时间轴'和'处理顺序'分为两类——在线操作和离线操作。理解它们的区别,是选择正确解题思路的前提。
定义:每一个查询操作都需要'即时处理、即时输出答案',不能等待后续操作输入后再统一计算。
特点:必须动态响应,处理顺序严格遵循输入顺序,无法改变操作的执行顺序。
适用场景:修改和查询操作穿插紧密,且查询结果依赖于实时的修改状态(如动态区间求和、单点查询等基础树状数组问题)。
例子:之前学的'单点修改 + 区间查询'模板题就是典型的在线操作——每次修改后可能紧跟查询,必须立即返回结果。
定义:先将所有的修改和查询操作全部读入(收集起来),然后根据问题的特性,重新安排操作的处理顺序,最后按原查询的时间轴输出答案。
特点:不即时响应查询,允许'打乱操作顺序',通过排序、重排等方式简化计算,往往能将复杂问题转化为树状数组可处理的'单点修改 + 区间查询'模型。
适用场景:查询结果不依赖于实时修改顺序,仅依赖于最终的某个状态(如统计区间内不同元素个数、区间内出现次数≥2 的元素个数等)。
例子:后面要讲的'HH 的项链'问题——查询区间内不同贝壳的种类数,若用在线操作会超时,但用离线操作 + 树状数组可将时间复杂度优化至 O(nlogn)。
| 对比维度 | 在线操作 | 离线操作 |
|---|---|---|
| 处理顺序 | 严格遵循输入顺序 | 可重新排序处理 |
| 响应方式 | 即时响应查询,即时输出 | 统一处理后,按原顺序输出 |
| 数据依赖 | 依赖实时修改状态 | 依赖最终或特定阶段的状态 |
| 代码复杂度 | 较低(直接套用模板) | 较高(需处理操作排序、结果映射) |
| 时间效率 | 通常为 O(m logn)(m 为操作数) | 往往更优(通过排序减少冗余计算) |
| 适用问题 | 基础区间统计(求和、单点查询等) | 复杂区间统计(不同元素数、特定出现次数统计等) |
很多复杂问题无法用在线操作直接解决,核心原因是'查询条件复杂'(如统计区间内不同元素个数)——若按在线思路,每次查询都要遍历区间,时间复杂度会达到 O(nm),对于 n=1e6、m=1e6 的场景完全超时。
而离线操作的核心优势的是:通过排序,将'复杂查询'转化为树状数组可高效处理的'单点修改 + 区间查询'模型。例如:
对于'区间内不同元素个数'问题,离线操作可按'查询右端点排序',遍历数组时用树状数组标记元素的最新出现位置,查询时直接统计区间内的标记数,即可得到不同元素的个数。
这种'化繁为简'的转化,正是离线操作的魅力所在。
这是离线操作 + 树状数组的经典入门题,也是算法竞赛中的高频考点。通过这道题,我们能掌握离线操作的核心流程:'读入所有操作→排序查询→处理数组→树状数组统计→映射答案'。
题目链接:https://www.luogu.com.cn/problem/P1972

如果用在线思路:对于每个查询 [l, r],遍历区间内所有元素,用哈希表统计不同种类数。时间复杂度为 O(mn),当 n=1e6、m=1e6 时,总操作数达到 1e12,完全超时。
因此必须用离线操作,核心思路是'按查询右端点排序,遍历数组时标记元素最新出现位置,用树状数组统计区间内标记数'。
对于数组中的每个元素 a[i],如果它在之前已经出现过(记上一次出现位置为 last[a[i]]),那么上一次的位置就失去了'贡献'(因为当前位置 i 是更靠右的出现位置,查询右端点≥i 时,只会统计 i 而不会统计 last[a[i]])。
因此,我们可以用一个树状数组维护'元素是否是当前区间内的最新出现位置':
遍历数组到位置 i 时,若 a[i] 之前出现过,先将树状数组中 last[a[i]] 的位置标记为 0(取消贡献);再将树状数组中位置 i 标记为 1(标记为最新出现位置);对于所有右端点为 i 的查询 [l, i],区间内不同元素的个数 = 树状数组中 [l, i] 的区间和(即标记为 1 的位置个数)。
遍历数组 + 维护树状数组:
用 last 数组记录每个元素的上一次出现位置(初始为 0);遍历数组 a[1..n]: a. 若 last[a[i]]≠0,执行树状数组单点修改(last[a[i]], -1)—— 取消上一次的贡献; b. 执行树状数组单点修改(i, 1)—— 标记当前位置为最新出现位置; c. 更新 last[a[i]] = i; d. 处理所有右端点 r=i 的查询:查询树状数组中 [l, i] 的区间和,将结果存入答案数组(按原始编号映射);
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
#define lowbit(x) (x & -x)
const int N = 1e6 + 10; // 适配 n 和 m 的最大范围
int n, m;
int a[N]; // 项链的贝壳种类数组
int tree[N]; // 树状数组:维护位置是否为最新出现位置(1 表示是,0 表示否)
int last[N]; // 记录每个贝壳上一次出现的位置(初始为 0)
int ans[N]; // 存储最终答案,按查询原始编号排列
// 查询结构体:存储 l、r、原始编号 id
struct Query {
int l, r, id;
} q[N];
// 排序规则:按查询的右端点 r 从小到大排序
bool cmp(Query& x, Query& y) {
return x.r < y.r;
}
// 树状数组单点修改:位置 x 的数值增加 k(k=1 或 -1)
void modify(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
tree[i] += k;
}
}
// 树状数组区间查询:查询 [1, x] 的前缀和
int query(int x) {
int sum = 0;
( i = x; i > ; i -= (i)) {
sum += tree[i];
}
sum;
}
{
(r) - (l - );
}
{
ios::();
cin.();
cout.();
cin >> n;
( i = ; i <= n; i++) {
cin >> a[i];
}
cin >> m;
( i = ; i <= m; i++) {
cin >> q[i].l >> q[i].r;
q[i].id = i;
}
(q + , q + + m, cmp);
pos = ;
(last, , last);
( i = ; i <= n; i++) {
current = a[i];
(last[current] != ) {
(last[current], );
}
(i, );
last[current] = i;
(pos <= m && q[pos].r == i) {
l = q[pos].l;
id = q[pos].id;
ans[id] = (l, i);
pos++;
}
}
( i = ; i <= m; i++) {
cout << ans[i] << endl;
}
;
}
查询结构体与排序:用
Query结构体存储查询的 l、r 和原始编号 id,按 r 排序后,保证遍历数组到 i 时,能处理所有右端点为 i 的查询; last 数组:记录每个元素的上一次出现位置,用于取消旧位置的贡献,确保树状数组中只有'最新出现位置'被标记为 1; 树状数组操作:modify用于标记或取消标记位置,interval_query通过前缀和差得到区间内不同元素的个数; 时间复杂度:排序查询的时间为 O(m logm),遍历数组和处理查询的时间为 O(n logn + m logn),总时间复杂度为 O((n+m) logn),可轻松处理 1e6 级数据。
这道题是'HH 的项链'的进阶版,要求统计区间内'出现次数≥2'的元素个数,进一步考验对离线操作和树状数组的灵活运用。
题目链接:https://www.luogu.com.cn/problem/P4113

HH 的项链统计'出现≥1 次'的元素个数,而本题统计'出现≥2 次'的元素个数,核心区别在于:
元素第一次出现:无贡献; 元素第二次出现:产生 1 点贡献(此时出现次数≥2); 元素第三次及以上出现:贡献不变(仍为 1 点,因为种类数只算一次)。
因此,需要维护两个数组:
last[x]:颜色 x 上一次出现的位置;llast[x]:颜色 x 上上次出现的位置。
通过这两个数组,判断当前元素出现次数是否达到 2 次,进而更新树状数组的贡献。
当颜色 x 第一次出现(last[x] = 0):无贡献,仅更新 last[x] = 当前位置 i; 当颜色 x 第二次出现(llast[x] = 0):产生 1 点贡献,将 last[x](上一次位置)标记为 1(因为查询右端点≥i 时,last[x] 在区间内则 x 出现≥2 次),更新 llast[x] = last[x],last[x] = i; 当颜色 x 第三次及以上出现(llast[x] ≠ 0):贡献不变,先取消 llast[x] 的标记(因为上上次位置已不再影响'出现≥2 次'的统计),再标记 last[x] 的位置,更新 llast[x] = last[x],last[x] = i。
遍历数组 + 维护树状数组:
用 last[x] 记录 x 上一次出现位置,llast[x] 记录 x 上上次出现位置(初始均为 0); 遍历数组 x[1..n]: a. 根据 last[x[i]] 和 llast[x[i]] 的状态,更新树状数组的标记(取消旧贡献、添加新贡献); b. 更新 last[x[i]] 和 llast[x[i]]; c. 处理所有右端点 r=i 的查询:查询区间 [l, i] 的和(即出现≥2 次的颜色种类数),存入答案数组;
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
#define lowbit(x) (x & -x)
const int N = 2e6 + 10; // 适配 n=2e6 的范围
int n, c, m;
int x[N]; // 花的颜色数组
int tree[N]; // 树状数组:维护颜色出现≥2 次的贡献(1 表示有贡献,0 表示无)
int last[N], llast[N]; // last[x]:x 上一次出现位置;llast[x]:x 上上次出现位置
int ans[N]; // 存储答案,按查询原始编号排列
// 查询结构体
struct Query {
int l, r, id;
} q[N];
// 排序规则:按右端点 r 从小到大排序
bool cmp(Query& a, Query& b) {
return a.r < b.r;
}
// 树状数组单点修改:位置 pos 的数值增加 k(k=1 或 -1)
void modify(int pos, int k) {
for (int i = pos; i <= n; i += lowbit(i)) {
tree[i] += k;
}
}
// 树状数组区间查询:[1, pos] 的前缀和
int query(int pos) {
int sum = 0;
( i = pos; i > ; i -= (i)) {
sum += tree[i];
}
sum;
}
{
(r) - (l - );
}
{
ios::();
cin.();
cout.();
cin >> n >> c >> m;
( i = ; i <= n; i++) {
cin >> x[i];
}
( i = ; i <= m; i++) {
cin >> q[i].l >> q[i].r;
q[i].id = i;
}
(q + , q + + m, cmp);
pos = ;
(last, , last);
(llast, , llast);
( i = ; i <= n; i++) {
color = x[i];
(!last[color]) {
last[color] = i;
} (!llast[color]) {
(last[color], );
llast[color] = last[color];
last[color] = i;
} {
(llast[color], );
(last[color], );
llast[color] = last[color];
last[color] = i;
}
(pos <= m && q[pos].r == i) {
l = q[pos].l;
id = q[pos].id;
ans[id] = (l, i);
pos++;
}
}
( i = ; i <= m; i++) {
cout << ans[i] << endl;
}
;
}
贡献维护逻辑:
第一次出现:仅记录位置,无贡献; 第二次出现:标记上一次位置为 1(此时该颜色在区间内出现≥2 次,产生贡献); 第三次及以上出现:取消上上次位置的标记(因为上上次位置已被上一次位置覆盖,不再影响'出现≥2 次'的统计),标记上一次位置为 1;
通过两道离线例题的学习,我们可以总结出选择在线或离线操作的核心判断标准:
对于树状数组相关的离线问题,可遵循以下模板:
在线操作和离线操作是树状数组进阶的核心思想,尤其是离线操作,能让看似复杂的区间统计问题变得简单可解。通过本文的两道例题,我们不仅掌握了'区间不同元素个数''区间出现≥2 次元素个数'的解法,更理解了离线操作'排序 + 树状数组统计'的核心逻辑。
学习离线操作的关键在于'转化思维'——不要被问题的表面复杂度吓住,而是思考如何通过排序、重排操作,将复杂问题转化为树状数组可处理的'单点修改 + 区间查询'模型。多做类似题目(如洛谷 P3605、P4054 等),就能熟练掌握这种思维方式。
希望本文能帮助你打通树状数组的离线操作难关,在算法竞赛和笔试中应对各类复杂区间统计问题!

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online