替罪羊树与KD-Tree

前言

替罪羊树是一种平衡树。对于平衡树的二叉搜索树性质,已经在这个博客比较详细地提到过了。那篇博客内,详细讲了的平衡树是 Splay。注意到 Splay 维护平衡的时候会直接通过旋转改变树的结构,在写平衡树套什么东西的时候,Splay 维护起来可能会有写吃力。因此这里重点写一下替罪羊树。

KD-Tree 和替罪羊树维护树平衡的方法一样,因此也可以提出来讲讲。

其实重点是KD不是替罪羊

替罪羊树

平衡树的插入,删除等操作基本都是一致的,重要的是如何维护树的平衡。例如给出一个不太平衡的树:

虽然单次查询不至于退化成 O(n)\operatorname{O}(n),但相对 log\log 的级别也够慢了。我们发现比较不平衡的两个典型,就是 10106464 的两个子树。

替罪羊树的思想很简单,现在既然这两个地方不平衡,那我直接把这两个子树重建一下就可以了。

平衡多了。但是众所周知,我不能每次修改一下就要全部重新把不平衡的地方重建。为了保证复杂度,应该需要衡量一个平衡因子 α\alpha,其中 α(0.5,1)\alpha\in(0.5,1),如果一个节点的左右子树中,较大的那个子树占比达到了 α\alpha,就给这个子树重构一下。合适的 α\alpha 可以控制替罪羊树的复杂度。

一般而言,α\alpha 取到 [0.65,0.75][0.65,0.75] 就够了。如果你的 α\alpha 取到了 0.90.9 才能保证不出锅,多半就是写假了。没有写假、平衡因子设置合理的替罪羊树单次操作复杂度基本 log\log 级。

具体的重构实现,可以先把要重构的子树拍扁,即按照中序遍历得到对应的序列,然后对于这个序列重新构建子树即可。每次重新构建的时候,应该以中位数对应的节点当作根节点,因为前面是在替罪羊树上中序遍历得到的序列,已经有序了,直接取中间位置就是中位数。

和 Splay 不同的地方还有替罪羊的删除操作。替罪羊树并不能很好地直接删除,因此通常以打标记的方法,如果这个数字被删掉了(出现次数为 00),在下一次重构的时候碰到这个点就不管它了。如果写的简单一点也可以直接忽视删除操作,保留原节点,大部分情况都不会被卡。

具体细节可以参考如下代码:

struct SheepTree{
	int root,tot,tmp;
	int pb[max_n]; // 记录拍扁时的中序遍历数组
	SheepTree(){root=tot=tmp=0;}
	const double alpha=0.75;
	struct dot{
		int ls,rs,sz,cnt,sd,sm,val; // 左右儿子、sz、出现次数、sd、sm、维护的值
		dot(){ls=rs=sz=cnt=sd=sm=val=0;}
	}a[max_n];
	#define ls(x) (a[x].ls)
	#define rs(x) (a[x].rs)

	inline void resize(int &x){
		a[x].sz=a[ls(x)].sz+a[rs(x)].sz+(bool)(a[x].cnt); // sz 去掉删掉的点的子树大小
		a[x].sd=a[ls(x)].sd+a[rs(x)].sd+1; // 直接统计节点数的子树大小(数重复出现算一次)
		a[x].sm=a[ls(x)].sm+a[rs(x)].sm+a[x].cnt; // 统计子树对应了实际上的多少个数(重复出现算多次)
	}
	inline bool regain(int &x){ // 判断需不需要重构
		if(!a[x].sm) return 0; // 被删掉了
		return (alpha*a[x].sd<=max(a[ls(x)].sd,a[rs(x)].sd) || alpha*a[x].sd>=a[x].sz);// 子树不平衡,或者删掉了比较多的点影响平衡
	}
	inline void get(int x){ // 中序遍历得到拍扁序列
		if(ls(x)) get(ls(x));
		if(a[x].cnt) pb[++tmp]=x;
		if(rs(x)) get(rs(x));
	}
	inline int build(int l,int r){ // 建树(重构)
		if(l==r){
			ls(pb[l])=rs(pb[l])=0;
			resize(pb[l]);
			return pb[l];
		}
		int mid=(l+r)>>1; // 取中间位置对应的节点,重造
		ls(pb[mid])=rs(pb[mid])=0;
		if(l<=mid-1) ls(pb[mid])=build(l,mid-1); // 读取左右儿子关系
		if(r>=mid+1) rs(pb[mid])=build(mid+1,r);
		resize(pb[mid]);
		return pb[mid]; // 返回当前子树的根节点
	}
	inline void rebuild(int &x){ // 重构树的全流程
		tmp=0;
		get(x); // 中序遍历
		x=build(1,tmp); // 重构这个点的子树
	}
	inline void pushup(int &x){ // 更新节点
		resize(x); // 更新大小等信息
		if(regain(x)) rebuild(x); // 如果需要则重构
	}
    // 剩下的都是板子了
	inline void insert(int &x,int val){
		if(!x){
			x=++tot;
			a[x].val=val,a[x].cnt=1;
			resize(x);
			return;
		}
		if(val==a[x].val) ++a[x].cnt;
		else if(val<a[x].val) insert(ls(x),val);
		else insert(rs(x),val);
		pushup(x);
	}
	inline void delet(int &x,int val){
		if(a[x].val==val) --a[x].cnt;
		else if(val<a[x].val) delet(ls(x),val);
		else delet(rs(x),val);
		pushup(x);
	}
	inline int rank(int &x,int val){
		if(!x) return 1;
		if(val==a[x].val) return a[ls(x)].sm+1;
		else if(val<a[x].val) return rank(ls(x),val);
		else return a[ls(x)].sm+a[x].cnt+rank(rs(x),val);
	}
	inline int kth(int &x,int k){
		if(!x) return -1;
		if(k>a[ls(x)].sm && k<=a[ls(x)].sm+a[x].cnt) return a[x].val;
		else if(k<=a[ls(x)].sm) return kth(ls(x),k);
		else return kth(rs(x),k-a[ls(x)].sm-a[x].cnt);
	}
	inline int pre(int val){
		return kth(root,rank(root,val)-1);
	}
	inline int suf(int val){
		return kth(root,rank(root,val+1));
	}
}shp;

KD-Tree

建树

KD-Tree 是一个优雅的暴力,复杂度相对可观,但是比较裸的 KD-Tree 容易被卡常。来看一下这样一个题目:

例题:在线地支持两个在平面直角坐标系上的操作:插入一个坐标为 (x,y)(x,y),点权为 pp 的点,查询一个矩形范围内的点权和。操作数 2×1052\times 10^5,点权和坐标在 int\text{int} 范围内,时间限制 3s,空间限制 20MB。

强制在线叉掉了 CDQ,20MB 的空间限制叉掉了树套树,真是毒瘤妈妈给毒瘤开门——毒瘤到家了。

因此我们就需要一个更加优秀的数据结构,可以很好地处理这个问题。KD-Tree 就是一个不错的选择。我们知道平衡树可以做到在一维(序列)的空间上对点进行很多高级的操作,KD-Tree 就是针对 K 维空间进行的。

为了简单画图和叙述,以二维空间为例子,看看这样子一个平面。

如果是在一个一维序列上,我们就会遵循 BST 的性质,每次取当前区间的中位数作为根,然后递归地建树。这样建出来的树一定具有 SBT 性质,随后怎么在加点删点时维护平衡就是它自己的事情了。

如果能找到一个标准,使得平面上的点也能建在一个树上,然后尽可能地满足 SBT 性质,那么复杂度就是接近 log\log 级别的。

KD-Tree 并不把事情搞的很复杂,每次我们只需要选择一个维度,然后在当前区间内找到中位数位置划分即可。当然每次选择维度不宜都是同一个,不然非常容易退化,常见的方法是交替选维度。

例如先选择 xx 轴为根的选择标准,找到点 D\text{D}

然后换一个维度,即 yy 轴维度,在得到的两个区间中各找到中位数点,注意到 B,G\text{B,G} 在同一线上,为了方便处理就随便选一个,另一个到时候再分。这里假设选择的是为 B,F\text{B,F}G\text{G}A\text{A} 视为在同一区域,不和 C\text{C} 同一区域。

然后再按照 xx 轴分一下,再按照 yy 轴……以此类推,最后得到的图可能是这样:

然后按照划分的顺序,假设对于 xx 轴,左边为左子树部分,右边为右子树部分;对于 yy 轴,上面为左子树部分,下面为右子树部分,就可以得到这样的一个树:

我们发现这个树看起来挺平衡的,那就可以根据这个关系来建树就行,控制了随机数据下在树上进行的操作复杂度约为 log\log 级别。

至于如何找到中位数,可以用 nth_element 函数,可以 O(n)\operatorname{O}(n) 找到一个序列的中位数并把它放在目标位置。

重构

这样就出现了一个问题,因为加点的时候一定是加在一个叶子上的,如果我一直往一个区域加一堆点,这个子树的根就不一定是原来的中位数了。然后加着加着这个树就不平衡了,复杂度就退化了。

只需要和替罪羊树一样,搞上一个平衡因子 α\alpha,如果子树之间不平衡了就暴力拆下来重构即可。可以发现只要 α\alpha 定得好,复杂度还可以稳定在 log\log 级别。

然后到这里上面那个例题就可以做出来了,我们再维护一下一个点子树内的点权和,这个点本身的点权,和子数内所有点构成的最大矩形。询问的时候,如果询问矩形和这个点涵盖的矩形相离,不会造成贡献,如果询问区间包含了这个点涵盖的矩形,则直接返回这个点的子树点权和。

#include<cstdio>
#include<algorithm>
#include<queue>
#include<iostream>
using namespace std;

bool Begin;

const int max_n=200055;
inline int read(){
	int x=0;bool w=0;char c=getchar();
	while(c<'0' || c>'9') w|=c=='-',c=getchar();
	while(c>='0' && c<='9') x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return w?-x:x;
}
inline void write(int x){
	if(x<0) putchar('-'),x=-x;
	if(x>9) write(x/10);
	putchar(x%10^48);
}
inline bool interv(int mid,int l,int r){ // 判断 x 是否在区间 [l,r] 内
	if(l<=mid && mid<=r) return 1;
	return 0;
}
inline int max(int a,int b){return a>b?a:b;}
inline int min(int a,int b){return a<b?a:b;}

struct point{
	int x,y,val; // 横纵坐标,权值
	point(){x=y=val=0;}
}pos[max_n];
inline point makep(int a,int b,int c){
	point res;
	res.x=a,res.y=b,res.val=c;
	return res;
}
bool cmpt=0;
inline bool cmp(point i,point j){ // 根据 cmpt 决定是横坐标比较还是纵坐标比较
	if(cmpt) return i.x<j.x;
	return i.y<j.y;
}

int fre[max_n],tp; // 删除节点时腾出的空间,垃圾回收
struct KD_Tree{
	const double alpha=0.75; // 平衡因子
	int root,tot,tmp;
	bool chgr;
	KD_Tree(){root=tot=tmp=0;}
	inline int newnode(){ // 前面说的垃圾回收在这里用到了
		int x;
		if(tp) x=fre[tp--];
		else x=++tot;
		return x;
	}
	struct dot{
		point x,l,r;// 这个点位置,涵盖的矩形的左上角和右下角
		int tp,sz,ls,rs,sm; // 以哪个维度分,子树大小,左右儿子,子数点权和
		dot(){tp=sz=ls=rs=sm=0;}
	}a[max_n];
	#define ls(x) (a[x].ls)
	#define rs(x) (a[x].rs)

	inline void pushup(int x){
		a[x].l=a[x].r=a[x].x,a[x].sm=a[x].x.val; // 初始化信息
		a[x].sz=a[ls(x)].sz+a[rs(x)].sz+1; // 更新子树大小
		if(ls(x)){ // 维护子树点权和以及涵盖矩形
			a[x].sm+=a[ls(x)].sm;
			a[x].l.x=min(a[x].l.x,a[ls(x)].l.x),a[x].r.x=max(a[x].r.x,a[ls(x)].r.x);
			a[x].l.y=min(a[x].l.y,a[ls(x)].l.y),a[x].r.y=max(a[x].r.y,a[ls(x)].r.y);
		}
		if(rs(x)){
			a[x].sm+=a[rs(x)].sm;
			a[x].l.x=min(a[x].l.x,a[rs(x)].l.x),a[x].r.x=max(a[x].r.x,a[rs(x)].r.x);
			a[x].l.y=min(a[x].l.y,a[rs(x)].l.y),a[x].r.y=max(a[x].r.y,a[rs(x)].r.y);
		}
	}
	inline int build(int l,int r,int t){ // 建树,t 为当前划分维度
		int x=newnode(),mid=(l+r)>>1;
		cmpt=t;
		nth_element(pos+l,pos+mid,pos+r+1,cmp);
		a[x].tp=t,a[x].x=pos[mid],ls(x)=rs(x)=0;
		if(l<=mid-1) ls(x)=build(l,mid-1,t^1); // 交替划分
		if(r>=mid+1) rs(x)=build(mid+1,r,t^1);
		pushup(x);
		return x;
	}
	inline void get(int x){ // 和替罪羊一样,先拍扁
		if(ls(x)) get(ls(x));
		pos[++tmp]=a[x].x,
		fre[++tp]=x;
		if(x==root) chgr=1;
		if(rs(x)) get(rs(x));
	}
	inline void rebuild(int &x){ // 重新构造这个子树
		if(alpha*a[x].sz>max(a[ls(x)].sz,a[rs(x)].sz)) return; // 不需要重构
		tmp=0,chgr=0;
		get(x);
		x=build(1,tmp,0);
		if(chgr) root=x;
	}
	inline void insert(int &x,point pt,int t){ // 添加一个点,用 t 这个方法划分维度
		if(!x){
			x=newnode();
			a[x].sz=1,a[x].x=pt,a[x].tp=t;
			pushup(x);
			return;
		}
		cmpt=t;
		if(cmp(pt,a[x].x)) insert(ls(x),pt,t^1); // 交替划分
		else insert(rs(x),pt,t^1);
		pushup(x),rebuild(x);
	}
	inline int ask(int x,point i,point j){ // 询问区间和
		if(!x || a[x].l.x>j.x || a[x].r.x<i.x || a[x].l.y>j.y || a[x].r.y<i.y) return 0; // 不存在该点或者与询问矩形相离
		if(a[x].l.x>=i.x && a[x].r.x<=j.x && a[x].l.y>=i.y && a[x].r.y<=j.y) // 能涵盖到的矩形被询问矩形涵盖
			return a[x].sm;
		int res=0;
		if(interv(a[x].x.x,i.x,j.x) && interv(a[x].x.y,i.y,j.y)) res+=a[x].x.val; // 该点本身在询问矩形内
		res+=ask(ls(x),i,j)+ask(rs(x),i,j);
		return res;
	}
}kdt;

bool ndp=0,End; // ndp 表示是否强制在线
signed main(){
	//cerr<<"Memory: "<<1.0*(&Begin-&End)/1024/1024<<"MB\n"<<"\n";
	int lst=read();lst=0;
	while(1){
		int op=read();
		if(op==1){
			int x=read(),y=read(),z=read();
			if(ndp) x^=lst,y^=lst,z^=lst;
			kdt.insert(kdt.root,makep(x,y,z),0); // 插入一个点
		}
		if(op==2){
			int x=read(),y=read(),a=read(),b=read();
			if(ndp) x^=lst,y^=lst,a^=lst,b^=lst;
			write(lst=kdt.ask(kdt.root,makep(x,y,0),makep(a,b,0))),putchar('\n'); // 矩形范围用两个点来表示
		}
		if(op==3)
			break;
	}
	return 0;
}

实测最慢的点不超过 2s。事实上,交替选点的复杂度一般视为单次树上操作 n\sqrt{n} 级别的。

估价

假设把求矩形内点权和,改成查询离一个点的曼哈顿距离最近的点的距离。

我们发现上面的划分方法有一个很大的弊端,就是有可能一个点和另一个点很近,但是在树上并不出在相邻的位置。举个例子,上面那个图(如果懒得翻,这里也放一个):

E\text{E} 最近的点是 F\text{F},但是显然 F\text{F}E\text{E} 不成相邻关系。如果右边这一部分画得分散一点,搞不好离 E\text{E} 最近的是 G\text{G},那就连祖先关系都不是。

因此每次询问的时候,就只能暴力跑一遍整个树吗?那我还建这个树干嘛。因此需要考虑怎么进行优化。

如果当前我找到的这个点,它的能涵盖到的矩形,距离询问点的距离还大于等于当前找到的最优答案,那还要往这个点的子树找干什么。

这样的看起来显然的优化实际上可以让复杂度变得非常优秀。确定好一个合适的搜索顺序后,基本可以看为也是根号级别的。这就是 KD-Tree 的估价。

这里给出一个例子。

inline int check(int x,int i){ // 估价函数
	if(!x) return inf; // 不存在这个点,显然不能往这里搜
	return max(a[x].l.x-d[i].x,0)+max(d[i].x-a[x].r.x,0)+max(a[x].l.y-d[i].y,0)+max(d[i].y-a[x].r.y,0); // 点到矩形曼哈顿距离最小值
}
inline void query(int x,int id){
	if(a[x].id!=id) // 查寻点和我现在的点不同,更新答案
		ans=min(ans,abs(a[x].x.x-d[id].x)+abs(a[x].x.y-d[id].y));
	int al=check(ls(x),id),ar=check(rs(x),id); // 计算左右子树估价
	if(al<ar){ // 哪个子树更近,先走哪个,使得较优的答案更早算出来,减枝更多
		if(al<ans) query(ls(x),id); // 如果到矩形的距离都比当前的最有答案大,别走
		if(ar<ans) query(rs(x),id);
	}
	else{
		if(ar<ans) query(rs(x),id);
		if(al<ans) query(ls(x),id);
	}
}

例题

BZOJ2683 简单题

题目连接,就是上面引入 KD-Tree 的例题。

luogu4169 天使玩偶

题目连接,就是改成求曼哈顿距离最近的这个题。

BZOJ3489 A simple rmq problem

题目连接,大概题意如下:

对于一个给定的序列,要求强制在线地处理若干询问,每次询问给定 l,rl,r[l,r][l,r] 内最大的一个数字,满足这个数字在区间中只出现了一次。

n,m,ai2×105n,m,a_i\leqslant 2\times 10^5

简要题解 先求出每个数字上一次出现的位置和后一次出现的位置,问题转化为找到最大的一个数,使得其上一次出现位置在左端点前,下一次出现位置在右端点后。那么不妨以这个点的位置、这个数上次出现位置、这个数字下一次出现位置三个维度为坐标,值为点权,建立一个 3D-Tree。每次查询的时候在 3D-Tree 上找到符合条件的点的点权最大值即可。
可以参阅我的 KD-Tree 代码

BZOJ4154 Generating Synergy

题目连接,大概题意如下:

对于一个有根带点权树,支持两个操作:一是修改 xx 子树与 xx 距离不超过 kk 的点的点权,二是查询一个点的点权。

n,m105n,m\leqslant 10^5

简要题解 二维线段树显然可以做这个题,现在考虑如何用 KDTree 解决。
如果我们求出树上每个点的深度和其对应子树的 dfn 区间,记为 dep[i],dfn[i],out[i]。
那么对于一个点的子树修改,等价于修改满足 dfn[i] 在 dfn[x] 和 out[x] 之间,且 dep[i] 在 dep[x] 和 dep[x]+k 之间的点 i 的权值。
因此不妨用 dfn[i] 和 dep[i] 当作一个点的横坐标和纵坐标,单次修改就相当于矩形内所有点赋值、单点查寻。因为不需要动态地加点删点,只需要给 KD-Tree 搞上一个区间赋值的下传标记即可实现。
具体代码可以参考我的提交记录