题解 luogu.P1972&P4113 BIT维护前缀和的实例

题目:

        首先给出这两道题:

        luogu.P1972 [SDOI2009] HH的项链https://www.luogu.com.cn/problem/P1972

        luogu.P4113 [HEOI2012] 采花https://www.luogu.com.cn/problem/P4113

题意建模

        这两道题说的其实是一个东西,只不过第二道题是第一道题的扩展与变形。我们先来看第一道题。

        抽象一下题意,大致是询问一个区间内不同数的个数。第一反应是 Segment Tree,因为它足够强大,维护区间内不同颜色的个数,用染色法做。但是可惜,需要持久化,目前笔者没学,所以只能考虑其它办法。第二道题说的内容也差不多,所以直接看看思路。

算法分析

        怎样维护区间内不同个数的个数?

  1. 对于区间内相同的数字,只保留出现在最右边的那一个(从左到右遍历的顺序的情况下),
  2. 把所有查询按右端点排序(这一步是为了适应第一步,如果第一步只保留最左边的那一个,那么这个地方就是按左端点排序)。
  3. 从左到右扫描,对于每个数字 a[j] ,如果再次出现,我们就先删除它在上次出现时的位置做的标记,然后加上它在当前位置上的出现,同时记录它这次出现的位置。
  4. 扫描完当前查询的前面位置,更新已处理位置的左边界,然后记录答案,即前缀和的区间查询。

        看到这里,出现了前缀和。所以想到BIT维护前缀和,在O(log_{2}n) 的时间复杂度内完成查询。可以考虑开始编码。

参考程序

第一题

//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维护。

第二道题与第一道题的细节有点差异。

        首先,题目条件变成了区间内至少存在两个相同的数字,这个数字才做出贡献。所以,

  •  llast[] 记录某个数字上上次出现的位置;
  • last[] 记录某个数字上次出现的位置。
  • 第一次出现某个数字是没有用,只需要标记一下,不做单点的更新;
  • 第二次出现,要加上贡献。而且要加在第一次(上上次)出现的位置所对应的点上,换句话说,第二次(上次)将要更新,就更新到第一次的点上。这个需要理解一下。其实可以这么理解:为什么会更新?更新的原因是什么?自然是区间内存在至少两个相同数字。从左往右遍历,在更靠左的位置上有一个更新,意味着一定有两个相同数字。可以举两个例子看看:比如,序列6 6 8,如果加在第二次,BIT状态为0 1 0,询问[2,3] 就出错。如果加在第一次出现的位置上,就是1 0 0,这样就是正确的。具体的细节笔者还在思考,见“总结归纳”

         这就是第二道题更难一些,实现的细节更多一些,但是给人更多的启示,如下。

总结归纳

        第一道题加上贡献只要一个数就够了;

        第二道题加上贡献需要至少两个相同的数。

        这启示我们:如果某个题加上贡献需要 n 个相同的数,又该如何处理呢?

        笔者目前只思考到了用数组按照前两题的办法去暴力的存储,空间开销会非常大。还有更好的办法吗?有待思考、、、

统计相同数个数的一般解法

针对需要至少k个相同数才产生贡献的问题,可以采用以下树状数组的扩展处理方法:

数据结构设计

  • 使用哈希表记录每个数值出现的所有位置列表(类似P4113的处理方式)
  • 维护k个树状数组,分别记录不同出现次数的贡献 ‌

核心算法逻辑

  • 当某个数 x 第 m 次出现时(m \geq k)
    • 在前 m-k 次出现的树状数组位置减去贡献
    • 在第 m 次出现的树状数组位置加上贡献
  • 查询时只需在对应树状数组上进行区间求和

如何确定出现满足约束时更新的位置 

 在解决需要统计“至少出现 ‌k 次‌”的数字贡献问题时,树状数组的更新逻辑需分层处理每个数字的‌历史出现位置‌。

一、核心原理:贡献的触发条件与位置选择

  1. 贡献触发条件

    • 单个数字首次出现(位置 p1)时不产生贡献,因未满足 ‌k≥2‌ 的条件14。
    • 第二次出现‌(位置 p2)时,表明该数字已满足“至少出现两次”,需添加贡献1115。
  2. 贡献位置的选择

    • 若将贡献加在第二次出现的位置 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=1p2=3p3=5

  1. 第一次出现(位置1)‌:不更新贡献。
  2. 第二次出现(位置3)‌:
    • 在 ‌第一次位置 p1=1‌ 加贡献(标记“从位置1开始的区间已满足两次”)11。
  3. 第三次出现(位置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 值

  1. 更新规则

    • 当数字 x 第 ‌m‌ 次出现(位置 p_m )时:
      • 若 ‌m ≥ k‌:
        • 移除第 ‌m-k‌ 次位置的旧贡献(位置 p_{m-k})14。
        • 新增贡献到第 ‌m-k+1‌ 次位置(位置 p_{m-k+1} ),作为新有效组合的起点。
    • 原因‌:确保贡献点始终是当前最近 ‌k‌ 次出现的‌起始位置‌。
  2. 数据结构设计

    • 用哈希表存储每个数字的‌所有出现位置列表‌(如 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 次出现”的有效区间起点。此设计确保了查询时:

  1. 仅当区间包含完整 ‌k‌ 次出现时计数;
  2. 避免重复或遗漏贡献1417。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值