离线算法笔记

离线算法

对于部分数据结构活图论问题,离线是一个十分有用的操作。下面主要介绍一些巧妙离线处理的算法,比如离线二维数点、cdq分治、树上启发式合并、莫队等算法。

离线二维数点

模板讲解

给定一个仅有第一象限的平面直角坐标系,平面上已知有 \(n\) 个点,第 \(i\) 个点位于 \((x_i,y_i)\),每次询问,给出四个整数 \(x_1,y_1,x_2,y_2\),询问以 \((x_1,y_1)\) 为左下角,\((x_2,y_2)\) 的矩形覆盖了多少个点,落在边缘也算。

离线处理这些询问,对于每一个询问,我们将其拆分成两个询问,分别是“横坐标小于 \(x_1\) 且纵坐标位于 \([y_1,y_2]\) 的点的数量”与“横坐标小于等于 \(x_2\) 且纵坐标位于 \([y_1,y_2]\) 之间的点的数量”。把前者挂在横坐标 \(x_1-1\) 上,后者挂在 \(x_2\) 上。该询问最终答案显然是后者的答案减去前者的答案。

如此处理完毕后,我们让一条平行于 \(y\) 轴的线从左往右扫过这个平面,如果扫到一个点就将其加入集合,如果扫到了一个点挂着个询问,那么我们就要求出当前集合里面纵坐标位于 \([y_1,y_2]\) 区间内的点的数量。这个显然可以用树状数组维护。扫完之后就处理完所有的答案了,再统一输出。时间复杂度 \(O(n\log n)\)

这种通过树状数组与扫描线维护二维平面部分区域内的点的数量的方式,叫做离线二维数点。

模板题:洛谷 P10814 【模板】离线二维数点

代码:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6+5;
int n,q;
int a[maxn],c[maxn<<1],ans[maxn];
int lowbit(int x){return x&-x;}
int query(int x){int res=0;for(int i=x;i>0;i-=lowbit(i))res+=c[i];return res;}
void add(int x,int y){for(int i=x;i<=2000000;i+=lowbit(i))c[i]+=y;}
struct node{int x,id,v;};
vector<node> t[maxn];
int main(){
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n>>q;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=q;i++){
		int l,r,x;
		cin>>l>>r>>x;
		t[l-1].push_back({x,i,-1});
		t[r].push_back({x,i,1});
	}
	for(int i=1;i<=n;i++){
		add(a[i],1);
		for(auto d:t[i]){
			ans[d.id]+=d.v*query(d.x);
		}
	}
	for(int i=1;i<=q;i++)cout<<ans[i]<<'\n';
	return 0;
}

应用

离线二维数点的使用范围相当广,可以处理相当多的问题。我们通过几道例题来讲讲它的作用。

洛谷 P1908 逆序对

这题可以使用归并排序顺便解决,这里讲讲它的二维数点做法。

把题目转化成这样:

平面上 \(n\) 个点,第 \(i\) 个点位于 \((i,a_i)\)。对于每一个点,询问 \(j \lt i\)\(a_j \gt a_i\) 的数量,即对于每一个点,询问位于它左上方的点有多少个。最终答案就是每一个点的答案的和。这就变成模板题了。

洛谷 P1972 [SDOI2009] HH 的项链

注意到值域的范围有限制。

对于区间内部包含不同数或者判断互不相同这种问题,我们有一个套路:对于第 \(i\) 个元素,维护相同的元素上一次出现的位置记为 \(b_i\)。如果在 \(i\) 位置的元素是在区间 \([l,r]\) 内第一次出现,当且仅当 \(b_1 \lt l\)

对于区间 \([l,r]\) 内部不同元素个数,则可以转化为,\(a_l,a_{l+1},\dots,a_r\) 是第一次在 \([l,r]\) 区间内出现的元素的个数,即对于 \(l \le i \le r\) 的所有 \(i\) 满足 \(b_i \lt l\)\(i\) 的个数,就变成了离线二维数点的模板题。

洛谷 P3899 [湖南集训] 更为厉害

\(dep_i\) 表示树上 \(i\) 节点的深度,\(size_i\) 表示树上以 \(i\) 节点为根的子树大小,\(dfn_i\) 表示节点 \(i\)\(dfs\) 序中的位置。

注意到对于一组合法的 \((a,b,c)\),满足 \(a\) 为固定节点 \(p\),且 \(a\)\(b\) 均为 \(c\) 的祖先,于是只有两种情况:

  1. \(b\)\(a\) 的祖先且深度差距不超过 \(k\)\(c\) 位于 \(a\) 的子树内。

  2. \(a\)\(b\) 的祖先且深度差距不超过 \(k\)\(c\) 位于 \(b\) 的子树内。

对于第一种情况,由于树上各级祖先是固定的,所以 \(b\) 就有 \(\min(dep_a-1,k)\) 种情况。此时 \(c\) 则是在 \(a\) 的子树内除去 \(a\) 节点任选一点即可,所以这种情况下共有 \(\min(dep_p-1,k) \times (size_p-1)\) 个不同三元组。

第二种情况就有些棘手,因为固定的只有节点 \(a\)\(b\)\(a\) 子树内不同点对应的 \(c\) 的可能选择数量是不同的。假如固定了节点 \(b\),那么 \(c\) 的可能选择就是 \(szie_b -1\)。这种情况下,三元组 \((a,b,c)\) 的数量就是 \(p\) 子树中与 \(p\) 节点深度差距不超过 \(k\) 的所有节点 \(i\)\(size_i-1\) 的总和。

我们知道在 \(dfs\) 序列中子树的下标是连续的,一个节点 \(i\)\(p\) 的子树内且不为 \(p\) 则可以转化成 \(dfn_p \lt dfn_i \le dfn_p+sz_p-1\)。节点 \(p\) 是固定的,所以节点 \(i\)\(p\) 的子树内且距离不超过 \(k\) 还需满足 \(dep_p \lt dep_i \le dep_p+k\)。也就是说,对于每一个询问我们需要找出同数满足两个限制的 \(i\)\(size_i -1\) 的总和。这又是二维数点的模板题。可能你会问这就不是询问平面内某区域的节点数量了,而是询问它们的权值和。其实两者没有区别,因为我们往树状数组中加入一个数就加上它的权值,这样求和的时候就能直接求出答案了。

洛谷 P6958 [NEERC 2017] The Great Wall

考虑二分答案 \(x\),问题转化为统计答案不超过 \(x\) 的方案数量是否大于等于 \(k\)。将 \(b_i\)\(c_i\) 都减去 \(a_i\) 的权值,统计答案最后再一起加上 \(a_i\) 即可。本题下文提到的 \(b_i\)\(c_i\) 均为减去 \(a_i\) 之后的值。有两种情况:

  1. 两区间没有交集

令两个区间分别为 \([s,s+r-1]\)\([t,t+r-1]\),不妨令 \(s < t\),则答案则是 \(\sum\limits_{i=s}^{s+r-1} b_i\ + \sum\limits_{i=t}^{t+r-1} b_i\)。前缀和优化一下,令 \(pre_i = \sum\limits_{j=1}^{i} b_j\),则答案贡献变为 \(pre_{s+r-1}-pre_{s-1}+pre_{t+r-1}-pre_{t-1}\),令 \(f_i = pre_{i+r-1}-pre_{i-1}\),则答案变为 \(1 \le s \le n-2r+1,s+r \le t \le n-r+1,f_s+f_t \le x\)\((s,t)\) 数量。这里的离线二维数点不能直接像模板一样一条线扫过去,关于 \(s\)\(t\) 的范围并不固定。可以使用双指针处理 \(s\)\(t\),正常访问树状数组即可。

  1. 两区间有交集

令两个区间分别为 \([s,s+r-1]\)\([t,t+r-1]\),不妨令 \(s < t\),则答案则是 \(\sum\limits_{i=s}^{t-1}b_i+\sum\limits_{i=t}^{s+r-1}c_i+\sum\limits_{i=s+r}^{t+r-1} b_i\)。令 \(pre2_i=\sum\limits_{i=1}^{i} c_i\),答案贡献变为 \(pre_{t-1}-pre_{s-1}+pre2_{s+r-1}-pre2_{t-1}+pre_{t+r-1}-pre_{s+r-1}\),令 \(g_i = pre2_{i+r-1}-pre_{i-1}-pre_{i+r-1},h_i=pre_{i-1}-pre2_{i-1}+pre_{i+r-1}\),方案贡献为 \(g_s+h_t\)。答案为 \(1 \le s \le n-r+1,s \le t \le \min(s+r-1,n-r+1),g_s+h_t \le x\)\((s,t)\) 数量。跟前面一样使用双指针离线二维数点即可。

cdq分治

模板讲解

给定 3 个长为 \(n\) 的序列 \(a_1,a_2,\dots,a_n\)\(b_1,b_2,\dots,b_n\)\(c_1,c_2,\dots,c_n\),对于每个点 \(i\) 询问满足 \(a_j \le a_i,b_j \le b_i,c_j \le c_i\)\(j\) 的数量。\(1 \le n \le 10^5,1 \le a_i,b_i,c_i \le 2 \times 10^5\)

这是一个经典的三维偏序问题。如果是二维偏序,我们可以使用上面说的离线二维数点做出。但是三维偏序显然不能这么做。下面介绍 cdq 分治。

我们先将所有的的元素按照 \(a_i\) 为第一关键字,\(b_i\) 为第二关键字,\(c_i\)为第三关键字从小到大排序,这样只有左边的元素能对右边的元素造成贡献。当然如果出现了重复元素就会出现问题,所以我么先将序列去重,最后再加上相同元素之间的贡献。

我们对序列进行分治,每次将序列从中间分开分成两部分,先递归求出两部分内部的答案,只计算左半部分对右半部分的贡献,这样就能不漏掉所有贡献。每一次,我们分别将左半部分和右半部分按照 \(b_i\) 排序。这虽然会破坏一开始排好序的序列顺序,但是此时我们已经计算完左半与右半的贡献,而且仅分别对两部分排序不会破坏两部分之间 \(a\) 序列的大小关系,对计算左半部分对右半部分的贡献没有影响。

显然只有 \(b_i \le b_j\) 时会产生贡献,所以我们在序列上使用双指针,左指针从区间左端点开始,右指针从右半部分左端开始,两指针位置分别记为 \(i,j\)。如果 \(b_i \le b_j\),则 \(b_i\) 会对 \(b_j\) 之后的所有元素造成贡献,将其加入集合,\(i \leftarrow i+1\)。如果 \(b_i \gt b_j\),则对 \(b_j\) 能造成贡献的已经全部在集合内部,则查找集合内点 \(c_k \le c_j\)\(k\) 的数量,显然这个集合应该使用树状数组维护。这个 \(k\) 的数量就是左半边对 \(j\) 产生的全部贡献,加入答案即可。

这个过程直到 \(i\) 达到左半部分的右端点或者 \(j\) 达到序列最右端时结束,如果结束后 \(j\) 还没达到最右端,那么需要把集合内元素对 \(j\) 右边的所有元素的贡献加上去。结束后需要清空集合。以上就是 \(cdq\) 分治的全部过程。时间复杂度 \(O(n \log ^2 n)\)

听完上面的过程,你可能觉得很熟悉,这不就是归并排序的过程吗?完全正确,也就是说我们每次做个双指针扫描的时候完全可以把排序顺便完成,那么每一次操作开头就不需要对左右两边单独排序了,可以优化一点时间复杂度,而且可以让代码显得很优雅。

模板题:洛谷 P3810 【模板】三维偏序 / 陌上花开

代码:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
int n,k,m,res[maxn];
struct node{
	int a,b,c,x,res;
	bool operator==(const node other)const{
		return a==other.a&&b==other.b&&c==other.c;
	}
	bool operator!=(const node other)const{
		return !(*this==other);
	}
}a[maxn],b[maxn],c[maxn];
bool cmp(node x,node y){
	return x.a==y.a?x.b==y.b?x.c<y.c:x.b<y.b:x.a<y.a;
}
struct BIT{
	#define lowbit(x) (x&-x)
	int c[maxn<<2];
	int query(int x){int res=0;for(int i=x;i>0;i-=lowbit(i))res+=c[i];return res;}
	void add(int x,int y){for(int i=x;i<=k;i+=lowbit(i))c[i]+=y;}
}bit;
void cdq(int l,int r){
	if(l==r)return;
	int mid=(l+r)>>1;
	cdq(l,mid);cdq(mid+1,r);
	int i=l,j=mid+1,p=l;
	while(i<=mid&&j<=r){
		if(a[i].b<=a[j].b){
			bit.add(a[i].c,a[i].x);
			c[p++]=a[i++];
		}else{
			a[j].res+=bit.query(a[j].c);
			c[p++]=a[j++];
		}
	}
	while(j<=r){a[j].res+=bit.query(a[j].c),c[p++]=a[j++];}
	for(int t=l;t<i;t++)bit.add(a[t].c,-a[t].x);
	while(i<=mid){c[p++]=a[i++];}
	for(int t=l;t<=r;t++)a[t]=c[t];
}
int main(){
	cin>>n>>k;
	for(int i=1;i<=n;i++)cin>>b[i].a>>b[i].b>>b[i].c;
	sort(b+1,b+n+1,cmp);
	for(int i=1;i<=n;i++){
		if(i==1||b[i-1]!=b[i]){
			a[++m]=b[i];
			a[m].x=1;
		}else{
			a[m].x++;
		}
	}
	cdq(1,m);
	for(int i=1;i<=m;i++)res[a[i].res+a[i].x-1]+=a[i].x;
	for(int i=0;i<n;i++)cout<<res[i]<<endl;
	return 0;
}

应用

cdq 分治可以用解决许多离线二维数点无法解决的问题,我们同样通过几道例题来讲解其作用

洛谷 P3157 [CQOI2011] 动态逆序对

如果没有删除操作这题就是前面提到的离线二维数点的第一道例题,但是有删除操作又该怎么做呢。

离线之后有一个比较巧的策略,就是这种带修改的操作可以新增加一个维度,就是时间维。记录每个元素被删除的时间,对于每一个被删除的元素,计算其删除后会影响多少个逆序对。被删除的时间记为 \(t_i\),元素下标记为 \(p_i\),元素的值记为 \(v_i\),那么每删除一个元素 \(i\),减少的逆序对数量即为 \(t_j>t_i,val_j<val_i,pos_j>pos_i\)\(t_j>t_i,val_j>val_i,pos_j<pos_i\) 的元素 \(j\) 的数量,于是便成了 cdq 分治的板子。

洛谷 P4390 [BalkanOI 2007] Mokia 摩基亚

这题跟离线二维数点的模板很像,但是会有单点修改的操作,跟刚才一样添加一个时间维,令每一个点被加入的时间为 \(t_i\),再记录每一次查询的时间 \(T\),那么矩形 \(x_1,x_2,y_1,y_2\) 求和就变成了求 \(x_1\le x_i\le x_2,y_1\le y_i\le y_2,t_i\le T\) 的元素数量,将其通过二维前缀和的方式拆分成四个不同的询问形如 \(x_i \le x,y_i \le y,t_i \le T\),就是三维偏序的模板了。

洛谷 P8575 「DTOI-2」星之河

树上 cdq 分治。由于 \(dfn\) 序中子树是连续的,一个节点 \(u\) 的子树内 \(a_v \le a_u,b_v \le b_u,v \neq u\)\(v\) 的数量相当于 \(dfn_u \lt dfn_v \le dfn_u+sz_u-1,a_v \le a_u,b_v \le b_u\)\(v\) 的数量,也是三维偏序的模板。

整体二分

模板讲解

给定一个长度为 \(n\) 的序列 \(a_1,a_2,\dots,a_n\)\(q\) 次操作,单点修改/区间查询第 \(k\) 小值。\(1 \le n,q \le 10^5\)

区间查询第 \(k\) 小值相当棘手,一般使用主席树解决,但是主席树无法处理修改的操作。我们需要用到一种离线的算法,叫做整体二分。

其实整体二分不能称作一种算法,而应该是一种思路。整体二分能解决的问题很多,满足下面条件的题目就可以使用整体二分解决。

  1. 支持离线
  2. 多次查询
  3. 查询满足单调性,可以二分

像这道题,区间查询第 \(k\) 小显然是满足单调性的,我们可以配合二维数点,猜一个 \(mid\),求出区间内小于 \(mid\) 的数的个数,如果大于 \(k\) 就往小了猜,否则往大了走。但是如果我们对每一次查询都做一遍这个操作显然就超时了,下面讲讲整体二分的思路。为了简单考虑,先讲讲不带修改的区间第 \(k\) 小的整体二分做法。

首先离线处理所有的询问,将其存起来。我们认为当前所有询问的答案都在 \(L,R\) 之间。然后我们枚举每一个询问用二分的 \(check\) 把询问分成两组,答案属于

posted @ 2026-08-05 20:59  AyBestEverBy  阅读(11)  评论(0)    收藏  举报