题目:
首先给出这两道题:
题意建模
这两道题说的其实是一个东西,只不过第二道题是第一道题的扩展与变形。我们先来看第一道题。
抽象一下题意,大致是询问一个区间内不同数的个数。第一反应是 Segment Tree,因为它足够强大,维护区间内不同颜色的个数,用染色法做。但是可惜,需要持久化,目前笔者没学,所以只能考虑其它办法。第二道题说的内容也差不多,所以直接看看思路。
算法分析
怎样维护区间内不同个数的个数?
- 对于区间内相同的数字,只保留出现在最右边的那一个(从左到右遍历的顺序的情况下),
- 把所有查询按右端点排序(这一步是为了适应第一步,如果第一步只保留最左边的那一个,那么这个地方就是按左端点排序)。
- 从左到右扫描,对于每个数字
,如果再次出现,我们就先删除它在上次出现时的位置做的标记,然后加上它在当前位置上的出现,同时记录它这次出现的位置。
- 扫描完当前查询的前面位置,更新已处理位置的左边界,然后记录答案,即前缀和的区间查询。
看到这里,出现了前缀和。所以想到BIT维护前缀和,在 的时间复杂度内完成查询。可以考虑开始编码。
参考程序
第一题
//P1972
#include<cstdio>
#include<iostream>
#include<algorithm>
#define lowbit(x) ((x)&(-(x)))
using namespace std;
const int N=1e6+5;
int t[N],n,m,last[N],ans[N],a[N];
struct task
{
int l,r,num;
bool operator < (const task &B)const { return this->r<B.r; }//重载运算符,等价于comp函数与lamban表达式
}ask[N];
inline int read()
{
int f=1,x=0;
char c=getchar();
while(c<'0'||c>'9') { if(c=='-') f=-1; c=getchar(); }
while(c>='0'&&c<='9') { x=(x<<1)+(x<<3)+(c^48); c=getchar(); }
return x*f;
}
inline void point_update(int x,int d) { while(x<=n) t[x]+=d,x+=lowbit(x); }
inline int range_query(int x)
{
int res=0;
while(x) res+=t[x],x-=lowbit(x);
return res;
}
int main()
{
n=read();
for(int i=1;i<=n;i++) a[i]=read();
m=read();
for(int i=1;i<=m;i++)
//ask[i]=(task){read(),read(),read()};
ask[i].l=read(),ask[i].r=read(),ask[i].num=i;//做标记。输出时按照输入顺序
sort(ask+1,ask+m+1);//按照右端点排序
for(int i=1,lp=1;i<=m;i++)
{
for(int j=lp;j<=ask[i].r;j++)
{
if(last[a[j]]) point_update(last[a[j]],-1);//出现过,减去前面的,
point_update(j,1);//更新现在的
last[a[j]]=j;//标记这次出现的位置
}
lp=ask[i].r+1;
ans[ask[i].num]=range_query(ask[i].r)-range_query(ask[i].l-1);查询
}
for(int i=1;i<=m;i++) printf("%d\n",ans[i]);
return 0;
}
第二题
//P4113
#include<iostream>
#include<cstdio>
#include<algorithm>
#define lowbit(x) (x&(-(x)))
using namespace std;
const int N=2e6+5;
struct task{
int l,r,num;
bool operator < (const task &B)const { return this->r<B.r; }
}ask[N];
int t[N],ans[N],last[N],llast[N],a[N],n,c,m;//the last of last is llast
inline void point_update(int x,int d){ while(x<=n) t[x]+=d,x+=lowbit(x); }
inline int range_query(int x)
{
int res=0;
while(x) res+=t[x],x-=lowbit(x);
return res;
}
int main()
{
scanf("%d%d%d",&n,&c,&m);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=m;i++) scanf("%d%d",&ask[i].l,&ask[i].r),ask[i].num=i;
sort(ask+1,ask+m+1);
for(int i=1,j=1;i<=m;i++)
{
for(;j<=ask[i].r;j++)
if(!llast[a[j]]) llast[a[j]]=j;
else
{
if(!last[a[j]])
point_update(llast[a[j]],1),last[a[j]]=j;
else
point_update(llast[a[j]],-1),point_update(last[a[j]],1),
llast[a[j]]=last[a[j]],last[a[j]]=j;
}
ans[ask[i].num]=range_query(ask[i].r)-range_query(ask[i].l-1);
}
for(int i=1;i<=m;i++) printf("%d\n",ans[i]);
return 0;
}
细节研讨
对于BIT的作用的设计
首先我们必须要理解为什么一定要记录上次的位置,然后还要单点更新+1或者-1呢?
这是因为我们要求的是一个区间内不同数的个数,而将每个数出现的位置都记录下来是没有必要的,也是非常不现实的。所以只查询个数,我们就只维护一个数,只要出现,就更新+1,代表它出现过。这样查询到的前缀和就是不同数的个数(也就是1的个数)。所以涉及到前缀和,就使用BIT维护。
第二道题与第一道题的细节有点差异。
首先,题目条件变成了区间内至少存在两个相同的数字,这个数字才做出贡献。所以,
-
记录某个数字上上次出现的位置;
记录某个数字上次出现的位置。
- 第一次出现某个数字是没有用,只需要标记一下,不做单点的更新;
- 第二次出现,要加上贡献。而且要加在第一次(上上次)出现的位置所对应的点上,换句话说,第二次(上次)将要更新,就更新到第一次的点上。这个需要理解一下。其实可以这么理解:为什么会更新?更新的原因是什么?自然是区间内存在至少两个相同数字。从左往右遍历,在更靠左的位置上有一个更新,意味着一定有两个相同数字。可以举两个例子看看:比如,序列6 6 8,如果加在第二次,BIT状态为0 1 0,询问[2,3] 就出错。如果加在第一次出现的位置上,就是1 0 0,这样就是正确的。具体的细节笔者还在思考,见“总结归纳”
这就是第二道题更难一些,实现的细节更多一些,但是给人更多的启示,如下。
总结归纳
第一道题加上贡献只要一个数就够了;
第二道题加上贡献需要至少两个相同的数。
这启示我们:如果某个题加上贡献需要 个相同的数,又该如何处理呢?
笔者目前只思考到了用数组按照前两题的办法去暴力的存储,空间开销会非常大。还有更好的办法吗?有待思考、、、
统计相同数个数的一般解法
针对需要至少k个相同数才产生贡献的问题,可以采用以下树状数组的扩展处理方法:
数据结构设计
- 使用哈希表记录每个数值出现的所有位置列表(类似P4113的处理方式)
- 维护k个树状数组,分别记录不同出现次数的贡献
核心算法逻辑
- 当某个数
第
次出现时
:
- 在前
次出现的树状数组位置减去贡献
- 在第
次出现的树状数组位置加上贡献
- 在前
- 查询时只需在对应树状数组上进行区间求和
如何确定出现满足约束时更新的位置
在解决需要统计“至少出现 k 次”的数字贡献问题时,树状数组的更新逻辑需分层处理每个数字的历史出现位置。
一、核心原理:贡献的触发条件与位置选择
-
贡献触发条件
- 单个数字首次出现(位置
p1
)时不产生贡献,因未满足 k≥2 的条件14。 - 第二次出现(位置
p2
)时,表明该数字已满足“至少出现两次”,需添加贡献1115。
- 单个数字首次出现(位置
-
贡献位置的选择
- 若将贡献加在第二次出现的位置
p2
:- 当查询区间为
[p1, p2]
时,p2
在区间内会被计数,但p1
可能未被包含(如查询[p2, ∞]
),导致漏计数17。
- 当查询区间为
- 正确做法:加在第一次出现的位置
p1
- 逻辑:
p1
是满足“至少两次”的起始验证点。 - 效果:
- 若查询区间包含
p1
,则该数字的两次出现(p1
和p2
)一定在区间内(因p1 < p2
)。 - 若查询区间不包含
p1
(如[p1+1, p2]
),则仅p2
被计入,但此时该数字实际只出现一次,不应计数1517。
- 若查询区间包含
- 逻辑:
- 若将贡献加在第二次出现的位置
二、操作示例(k=2)
假设数组 [3, 5, 3, 7, 3]
,数字 3
的位置为 p1=1
, p2=3
, p3=5
:
- 第一次出现(位置1):不更新贡献。
- 第二次出现(位置3):
- 在 第一次位置
p1=1
加贡献(标记“从位置1开始的区间已满足两次”)11。
- 在 第一次位置
- 第三次出现(位置5):
- 移除
p1=1
的旧贡献(因新序列[p2=3, p3=5]
才是当前有效的“两次组合”)14。 - 在 第二次位置
p2=3
加新贡献(新起始点)。
- 移除
查询示例:
- 查询区间
[1, 3]
:包含p1=1
(贡献点),计数+1
(对应3
出现两次)。 - 查询区间
[2, 3]
:不包含p1=1
,不计入3
(因3
在[2,3]
内实际只出现一次)1517。
三、推广到任意 k 值
-
更新规则
- 当数字
x
第 m 次出现(位置)时:
- 若 m ≥ k:
- 移除第 m-k 次位置的旧贡献(位置
)14。
- 新增贡献到第 m-k+1 次位置(位置
),作为新有效组合的起点。
- 移除第 m-k 次位置的旧贡献(位置
- 若 m ≥ k:
- 原因:确保贡献点始终是当前最近 k 次出现的起始位置。
- 当数字
-
数据结构设计
- 用哈希表存储每个数字的所有出现位置列表(如
unordered_map<int, vector<int>> pos
)。 - 为每个 k 值维护独立树状数组(如
vector<FenwickTree> ft
),分层管理贡献。
- 用哈希表存储每个数字的所有出现位置列表(如
四、复杂度与优化
操作 | 时间复杂度 | 说明 |
---|---|---|
单次更新 | O(k log n) | 需更新 k 个树状数组 |
单次查询 | O(log n) | 单树状数组查询 |
空间复杂度 | O(n + m) | m 为不同数字数量 |
优化方向:
- 离散化:对稀疏数据压缩值域11。
- 离线处理:配合扫描线算法,总复杂度优化至 O(n log n)。
总结
通过将贡献绑定到第 m-k+1 次出现的位置,树状数组能精准标记“连续 k 次出现”的有效区间起点。此设计确保了查询时:
- 仅当区间包含完整 k 次出现时计数;
- 避免重复或遗漏贡献1417。