线段树分治

这是线段树的进阶用法了,不过只要理解了还是很好做的。

二分图 /【模板】线段树分治

这道题目非常的厉害,每条边竟然在特定时间才会出现。所以如果依次枚举时间,复杂度肯定是 \(\mathcal{O}\)\(((m+n)k)\) 起步。这种复杂度我们自然无法承受。但观察到时间最多只有 \(10^5\),我们是不是可以以时间为轴,建一颗线段树,在线段树上处理操作呢?

事实证明是可以的。因为我们需要递归子结点,递归完后再进行处理,所以有分治的思想,所以这种算法就叫作线段树分治

插入

首先我们得有一颗线段树吧。那么第一步是不是要建树呢?

然而事实是,我们并不需要建树。因为线段树并不需要维护区间和等信息,所以直接插入就可以了。

接下来进行一次插入操作。例如,插入一条连接 \(x\)\(y\) 的边,从 \(l\) 时刻开始,\(r\) 时刻消失。

首先注意到输入非常坑,因为时刻可以为 \(0\),所以可以对 \(l,r\) 进行偏移,即 \(l \to l + 1,r \to r + 1\)

其次发现 \(r\) 是消失时刻,所以说第 \(r\) 时刻这条边是不存在的。所以终点时刻还要减去 \(1\),即 \(r + 1 \to r\)

所以实际上一条边的出现时间为 \([l + 1,r]\)

解决完这个就可以上线段树了。众所周知线段树是长线段分裂成短线段。对于此题,因为线段树以时间为轴,所以最长的线段显然就是 \([1,k]\)

然后根据这条线段进行分裂操作。如果分裂到一条线段 \([L,R]\),均为线段的出现时间,那么我们在这个结点上打标记,表示 \([L,R]\) 的时间内都有一条连接 \(x\)\(y\) 的边。因为边数只有 \(m\) 条,且一条线段最多被分成 \(\log k\) 段,所以可以在 \(\mathcal{O}\)\((m \log k)\) 的时间内完成。

下面是此部分的代码。

void update(int X,int x,int y,int l,int r,int L,int R)
//X:结点编号 x,y:边的两个端点 l,r:边的出现时刻 L,R:线段树结点的左右端点
{
	if(l <= L && R <= r)//[L,R]被完全包含
	{
		ve[X].push_back(x),ve2[X].push_back(y);//记录
		return ;
	}
	int mid = (L + R) >> 1;//分裂
	if(l <= mid)update(X * 2 + 1,x,y,l,min(r,mid),L,mid);
	if(r > mid)update(X * 2 + 2,x,y,max(l,mid + 1),r,mid + 1,R);
}
for(int i = 1,x,y,l,r;i <= m;i ++)
{
	cin >> x >> y >> l >> r;
	if(l == r)continue ;//注意到l=r时边是不出现的,一定要进行特判!
	update(0,x,y,l + 1,r,1,k);//用映射后的左右端点进行插入
}

分治

接下来就是线段树分治的重点内容了。

首先我们要知道二分图的判定是什么。其实只要判断图是否有奇环就可以了。

为什么呢?首先我们知道如果存在将点分成两组(两种颜色)的方案使得没有边连接同组点,那么这个图就是二分图。那么可以发现,如果出现了环,那么必须是偶环,比如:\(A\)\(\to B\)\(\to A\)\(\to B\)\(\to\;... \to B\)\(\to A\) 组。显然路径长度是偶数,不可能会有奇环出现。

那么我们可以用种类并查集来判断图是否有奇环。没学过种类并查集的人可以看下面的做法:

我们把每个点都设置一个反集,如果这个点的下标是 \(i\),那么它的反集的下标就是 \(i + n\)

为什么叫反集呢?因为自己和自己的反集在两个不同的组(这就是为什么它叫作反集),如果我们对 \(x,y\) 连一条边,那么可以知道 \(x\)\(y\) 在两个不同的组,所以 \(x + n\)\(y\) 一定在两个相同的组。同理 \(x\)\(y + n\) 也在两个相同的组,所以我们只需要合并 \((x + n,y)\)\((x,y + n)\) 就行了。

说了这么多了大家是不是大概有思路了?

没错!当我们发现一个结点有边时,我们就按上面的操作进行合并。如果发现连的一条边的两个端点在同一个集合里,立即输出 No

下面是代码。

int flag = 0,last = cnt;
for(int i = 0;i < ve[x].size();i ++)
{
	if(find(ve[x][i]) == find(ve2[x][i]))//一条边的两个端点在一个集合,标记
	{
		for(int j = l;j <= r;j ++)ans[j] = 1;//1:No 0:Yes
		flag = 1;break ;
	}
	merge(ve[x][i],ve2[x][i] + n);//合并
	merge(ve2[x][i],ve[x][i] + n);
}
if(!flag && l != r)
{
	int mid = (l + r) >> 1;
	gogogo(x * 2 + 1,l,mid);//其实这是分治的函数,分裂成两部分继续操作
	gogogo(x * 2 + 2,mid + 1,r);
}

可撤销并查集

现在我们大致知道该怎么做了。不过我们会发现一个问题:我们在子树中的合并,不能带到另一个子树里面去,要进行撤销。但我们知道并查集是不支持撤销的。所以为此我们需要使用可撤销并查集

顾名思义,这种并查集支持撤销,因为它是按秩合并,不可以状态压缩,复杂度为 \(\mathcal{O}\)\((\log n)\)

接下来讲讲可撤销并查集的合并,因为我们让高度小的并查集树并到高度大的并查集树上去,并用一个栈记录上述过程。另外,如果两个并查集树的高度相同,则无论合并,高度都会加一。

void merge(int x,int y)
{
	x = find(x),y = find(y);//找到并查集树的根结点
	if(hi[x] > hi[y])swap(x,y);//若x比y高度高,交换
	stk[++cnt] = x,stk2[cnt] = y,stk3[cnt] = (hi[x] == hi[y]);//用栈来记录
	f[x] = y;//合并
	if(hi[x] == hi[y])hi[y] ++;//修改高度
}

这样在调用 \(\text{find}\)\(()\) 函数时,每次都复杂度都不会超过 \(\log n\)。证明:不难发现高度为 \(1\) 的树只需要 \(1\) 个结点,高度为 \(2\) 的树至少要 \(2\) 个结点,高度为 \(3\) 的树至少要 \(2 + 2 = 4\) 个结点,高度为 \(4\) 的树至少要 \(4 + 4 = 8\) 个结点 ... 发现规律了吗?

这样我们在撤销时,我们直接根据栈储存的信息把被合并的结点的父亲数组指向自己,并撤去合并被合并结点的高度。这部分的代码如下所示。

while(cnt > last)//cnt表示现在的栈顶,last表示分治前的栈顶
{
	hi[stk2[cnt]] -= stk3[cnt];//减去高度
	f[stk[cnt]] = stk[cnt];//指向自己
	cnt --;
}

时空复杂度

把这些细节处理掉之后,代码基本就出来了,所以我们来算算时空复杂度。

时间复杂度:注意到插入的线段最多会被分割成 \(\log k\) 条,而在并查集上查询父亲又要 \(\log n\) 的复杂度(因为 \(2n\) 个结点),那么总复杂度就是:\(\mathcal{O}\)\((m \log n \log k)\)

总代码

#include <iostream>
#include <vector>
using namespace std;
int n,m,k,f[200004],ans[300004],hi[300044];
int find(int x)
{
	if(f[x] == x)return x;
	return find(f[x]);
}
int stk[900005],stk2[900005],stk3[900005],cnt;
vector<int>ve[600004],ve2[600004];
void update(int X,int x,int y,int l,int r,int L,int R)
{
	if(l <= L && R <= r)
	{
		ve[X].push_back(x),ve2[X].push_back(y);
		return ;
	}
	int mid = (L + R) >> 1;
	if(l <= mid)update(X * 2 + 1,x,y,l,min(r,mid),L,mid);
	if(r > mid)update(X * 2 + 2,x,y,max(l,mid + 1),r,mid + 1,R);
}
void merge(int x,int y)
{
	x = find(x),y = find(y);
	if(hi[x] > hi[y])swap(x,y);
	stk[++cnt] = x,stk2[cnt] = y,stk3[cnt] = (hi[x] == hi[y]);
	f[x] = y;	
	if(hi[x] == hi[y])hi[y] ++;
}
void gogogo(int x,int l,int r)
{
	//cout << x << " " << l << " " << r << '\n';
	int flag = 0,last = cnt;
	for(int i = 0;i < ve[x].size();i ++)
	{
		if(find(ve[x][i]) == find(ve2[x][i]))
		{
			for(int j = l;j <= r;j ++)ans[j] = 1;
			flag = 1;break ;
		}
		merge(ve[x][i],ve2[x][i] + n);
		merge(ve2[x][i],ve[x][i] + n);
	}
	if(!flag && l != r)
	{
		int mid = (l + r) >> 1;
		gogogo(x * 2 + 1,l,mid);
		gogogo(x * 2 + 2,mid + 1,r);
	}
	while(cnt > last)
	{
		hi[stk2[cnt]] -= stk3[cnt];
		f[stk[cnt]] = stk[cnt];
		cnt --;
	}
}
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
	cin >> n >> m >> k;
	for(int i = 1;i <= 2 * n;i ++)f[i] = i;
	for(int i = 1,x,y,l,r;i <= m;i ++)
	{
		cin >> x >> y >> l >> r;
		if(l == r)continue ;
		update(0,x,y,l + 1,r,1,k);
	}
	gogogo(0,1,k);
	for(int i = 1;i <= k;i ++)cout << (ans[i] ? "No\n" : "Yes\n");
}

例题

P5227

判断图联通的方法大家都知道吧,找一个点所在联通块的大小,如果等于 \(n\) 就说明联通了。

CF1814F

CF1814F 这道题需要一个 trick,怎么判断一个点和点 \(1\) 连通呢?显然是看每个点和点 \(1\) 是不是在一个联通块。然而这样子做的话时间会爆,所以我们采用一种特别的方式来记录,就是访问到叶子结点时,在叶子处给 \(\text{find(1)}\) 这个结点打上标记(+1),在撤销合并时顺便把标记传出去。注意在合并时要先消除 \(y\) 集合带来的影响,否则可能会误判。

另外这道题并不是边消失,而是点消失。但我们发现一条边存在当且仅当连接的两个端点都存在,所以根据点存在的时间反推边存在的时间即可。

#include <iostream>
#include <vector>
using namespace std;
int n,m,k,f[200004],hi[300044],lan[300044];
int find(int x)
{
	if(f[x] == x)return x;
	return find(f[x]);
}
int stk[900005],stk2[900005],stk3[900005],cnt;
vector<int>ve[600004],ve2[600004];
void update(int X,int x,int y,int l,int r,int L,int R)
{
	if(l <= L && R <= r)
	{
		ve[X].push_back(x),ve2[X].push_back(y);
		return ;
	}
	int mid = (L + R) >> 1;
	if(l <= mid)update(X * 2 + 1,x,y,l,min(r,mid),L,mid);
	if(r > mid)update(X * 2 + 2,x,y,max(l,mid + 1),r,mid + 1,R);
}
void merge(int x,int y)
{
	x = find(x),y = find(y);
	if(x == y)return ;
	if(hi[x] > hi[y])swap(x,y);
	stk[++cnt] = x,stk2[cnt] = y,stk3[cnt] = (hi[x] == hi[y]);
	f[x] = y;	
	if(hi[x] == hi[y])hi[y] ++;
	lan[x] -= lan[y];//先消除影响,之后再加上(先减后加)
}
void gogogo(int x,int l,int r)
{
	int flag = 0,last = cnt;
	for(int i = 0;i < ve[x].size();i ++)
		merge(ve[x][i],ve2[x][i]);
	if(l != r)
	{
		int mid = (l + r) >> 1;
		gogogo(x * 2 + 1,l,mid);
		gogogo(x * 2 + 2,mid + 1,r);
	}
	else lan[find(1)] ++;//标记
	while(cnt > last)
	{
		hi[stk2[cnt]] -= stk3[cnt];
		f[stk[cnt]] = stk[cnt];
		lan[stk[cnt]] += lan[stk2[cnt]];
		cnt --;
	}
}
int qi[200005],zh[200005];
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
	cin >> n >> m;
	for(int i = 1;i <= n;i ++)f[i] = i;
	for(int i = 1;i <= n;i ++)cin >> qi[i] >> zh[i];
	for(int i = 1,x,y,l,r;i <= m;i ++)
	{
		cin >> x >> y;
		l = max(qi[x],qi[y]);
		r = min(zh[x],zh[y]);
		if(l <= r)update(0,x,y,l,r,1,200000);
	}
	gogogo(0,1,200000);
	for(int i = 1;i <= n;i ++)
		if(lan[i])cout << i << " ";
}
posted @ 2025-08-14 22:36  wuyixiang  阅读(48)  评论(0)    收藏  举报