【题目描述】
原题来自:Vijos P1448
校门外有很多树,学校决定在某个时刻在某一段种上一种树,保证任一时刻不会出现两段相同种类的树,现有两种操作:
K=1,读入 l,r 表示在 l 到 r 之间种上一种树,每次操作种的树的种类都不同;
K=2,读入 l,r 表示询问 l 到 r 之间有多少种树。
注意:每个位置都可以重复种树。
【输入】
第一行 n,m 表示道路总长为 n,共有 m 个操作;
接下来 m 行为 m 个操作。
【输出】
对于每个 k=2 输出一个答案。
【输入样例】
5 4
1 1 3
2 2 5
1 2 4
2 3 5
【输出样例】
1
2
【提示】
数据范围与提示:
对于 20% 的数据,1≤n,m≤100;
对于 %60% 的数据,1≤n≤10^3,1≤m≤5×10^4 ;
对于 %100% 的数据,1≤n,m≤5×10^4 ,保证 l,r>0。
一、 题目分析
【题目模型】 道路总长为N,共有M个操作交替进行:
-
操作 1: 在区间[l,r]种上一种全新的树(每次种类都不同)。
-
操作 2: 查询区间[L,R]内,一共包含了多少种树?
【核心:种类 = 线段相交】 题目故意用"种类"这个词来迷惑同学们。因为规定了"每次操作种的树种类都不同",所以每一次种树操作,在几何意义上就是画出了一条独一无二的线段 。 而"查询区间内有多少种树",本质上就是在问:在此之前画过的所有线段中,有多少条线段与当前的查询区间 [L,R] 发生了重叠(相交)?
二、 思考过程:正难则反的容斥原理
如果直接去数"有多少条线段和[L,R]相交",情况会极其复杂:线段可能被区间包含、可能跨越整个区间、也可能只在左边或右边蹭到一个点。
破局点:与其找"相交",不如找"绝对不可能相交"。
我们站在查询区间 [L,R] 的视角,所有的历史线段只可能分为三类:
-
A 类(全在左边,错过了): 线段的右端点r<L(即 r≤L−1)。
-
C 类(全在右边,错过了): 线段的左端点l>R。
-
B 类(发生相交,命中了): 排除掉A和C,剩下的就是我们要找的答案。
推导数学公式: 我们不难发现:如果一条线段的左端点≤R ,那么它绝对不可能是C类树。所以,这批线段里只包含了A类和B类。 接着,我们只要把这批线段里的A类(即右端点 ≤L−1 的线段)剔除出去,剩下的就百分之百是B类(相交的线段)了。
得出容斥公式:
相交种类数=(左端点≤R 的总数)−(右端点≤L−1 的总数)
三、 算法设计:双树状数组
既然我们需要频繁地求"小于等于某个坐标的端点总数",这不就是标准的频次桶的前缀和问题吗?我们只需要建立两个树状数组:
-
cl(起点桶): 维护每个位置作为"左端点"出现的频次。 -
cr(终点桶): 维护每个位置作为"右端点"出现的频次。
操作流转:
-
当种树[l,r]时: 给
cl的位置l加上 1,给cr的位置r加上 1。(两个单点修改) -
当查询 [L,R] 时: 答案=
query(cl,R)-query(cr,L-1)。(两个前缀和相减)
没有任何复杂的区间修改,没有任何Lazy Tag,仅仅靠着两个最简单的单点修改树状数组,就破解了这道思维难题。
四、 时空复杂度分析
-
时间复杂度: 每次操作只需要对树状数组进行两次 O(logn) 的单点修改或前缀查询。总共 M 次操作,总体时间复杂度为O(MlogN)。在5×10^4 的数据规模下,运算量不到百万,极速 AC。
-
空间复杂度: 仅开辟了两个长度为N的树状数组,空间复杂度O(N)。
五、 易错点总结
-
在写
update函数时,我们经常用一个变量x传入目标坐标,但在内部的for(int i=x; i <=n; i+=lowbit(i))循环中,真正需要被累加更新的是游标i,千万不能写成对固定坐标x累加。 (即c[i]+=val,绝不能是c[x]+=val)。 -
数据量达到数万级别,
main函数的第一行必须焊死ios::sync_with_stdio(false); cin.tie(0);,否则容易被卡常数超时。 -
涉及树状数组的前缀累加,统一使用
long long声明。
六、 完整代码
cpp
#include <iostream>
using namespace std;
typedef long long ll;
int n,m;
ll cl[500010];//cl[i]表示"以位置i为左端点"的区间数量前缀和树状数组
ll cr[500010];//cr[i]表示"以位置i为右端点"的区间数量前缀和树状数组
//返回x二进制表示下最低位1所代表的整数
int lowbit(int x){
return x&(-x);
}
//树状数组更新
void update(int id,int x,int val){
if(id==1){//修改左端点数组
for(int i=x;i<=n;i+=lowbit(i)){
cl[i]+=val;
}
}
else{//修改右端点数组
for(int i=x;i<=n;i+=lowbit(i)){
cr[i]+=val;
}
}
}
//查询 树状数组求前缀和(求指定频次桶的前x项总和)
ll query(int id,int x){
ll ret=0;
if(id==1){
while(x){
ret+=cl[x];
x-=lowbit(x);//顺着层级向左上跳跃,拼接相邻区间
}
}
else{
while(x){
ret+=cr[x];
x-=lowbit(x);
}
}
return ret;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
//总共执行m次操作
while(m--){
int k,l,r;
cin>>k>>l>>r;
if(k==1){//在l到r之间种上一种树
//一条线段产生,其左端点为l,右端点为r
update(1,l,1);
update(2,r,1);
}
else{//询问l到r之间有多少种树
//答案=(左端点在r及左侧的总数)-(右端点在l-1及左侧的总数)
cout<<query(1,r)-query(2,l-1)<<"\n";
}
}
return 0;
}