alphaGem's blog

Follow me on GitHub

伪Top Tree学习笔记

May 22, 2019
标签: 信息竞赛 数据结构


No te eches a la mar !

本文介绍的是静态的以Top Tree的部分思想和结构弄出来的可以维护树上动态DP的全局答案的东西,我也不知道该叫啥,或许就叫静态Top Tree或者是某种全局平衡二叉树,标题叫Top Tree是吸引你点进来的。刚发现似乎也有把AAA树叫做伪Top Tree的,所以这里消歧义一下。

  1. 前情提要
  2. 概述
  3. 尝试一下
    1. 建树
    2. 信息合并
    3. 代码
  4. 外部链接

前情提要

万恶之源:「SDOI2017」切树游戏

这题平凡的做法是用树剖线段树维护动态DP,网络上应该很多题解,这里就不再赘述了。要看题解可以移步这篇immortalCO的博客

为了试图用一个 $\log$ 做这题,学了个全局平衡二叉树,然后发现全局平衡二叉树没法维护信息不可减的情况。而且就算写了可除的 $\bmod 10007$ 类,仍然需要对于合并信息进行分类讨论啥的……

UOJ上的一篇介绍Top Tree的文章有提到Top Tree可以做这个题。今年集训队论文有一篇就是以Top Tree为主题的,还有一篇借用了Top Tree的rake与compress操作的思想……所以总觉得这是个该学的东西,于是就开坑了orz

然而事实上完整版的Top Tree难以实现,我不觉得我能在考场上写出来,有这时间写Top Tree还不如去做提交答案题。所以这里主要介绍的是静态Top Tree。就像全局平衡二叉树可以看成是静态版本的Link-Cut Tree一样。省去link, cut, expose等操作以后,静态的Top Tree就显得易于实现了。因为主要介绍的不是真正的Top Tree,所以叫做伪Top Tree学习笔记。

因为是学习笔记,所以可能直接扔链接,而不进行具体的介绍,侧重点放在实现细节。毕竟别人造过的轮子,为啥还要自己复读一遍呢……qwq

概述

Top Tree是一类用于维护树上信息的数据结构。Wikipedia: Top Tree

在看下面的内容之前,需要先了解一下

  1. 簇和一些相关的定义(考虑到收缩过程中边和簇其实很相似,所以下文可能混用)
  2. rake操作(并一度点操作,以下简称R操作)
  3. compress操作(缩二度点操作,以下简称C操作)
  4. Top Tree的应用范畴

这篇negiizhao的博客已经把大部分东西介绍得挺清楚的了。这篇文章同时还有给出很多的例题与应用范围之类的东西。读完基本上能对Top Tree有所了解。其中的Contraction-based top tree一段的内容是本文的主要方法。继续阅读之前,可以先通读上面这篇博客,理解主体思想。但是这篇博客内容比较高级,讲的是真的能动来动去的那种Top Tree,所以这里把一些对本文有用的信息摘录如下,这样我就不用把它们再写一遍了。

我们希望得到一种用于维护树上信息的数据结构,类似于序列上的平衡二叉树。

平衡二叉树每次把序列中的 2 段合并为 1 段,所以可以考虑在树上用类似的做法,把某两个“子树”合并为一个“子树”。

为了维护信息,我们希望这样的“子树”中只有不超过 2 个点和外部的点邻接,我们把这样的“子树”(和两个端点)称为簇(cluster)。

显然,没有进行合并时,所有簇都是表示一个边,表示原树的一个边的簇称为 base cluster。

考虑以下两种操作,它们是树收缩(tree contraction)的两个基本操作,rake 消除一个度为 1 的点,compress 消除一个度为 2 的点,此外这些操作会把两条边替换为一条边。

我们可以把这两种操作看作合并两个簇,显然这两个簇合并之后还是簇。合并后簇中和外部邻接的点是图上新边的端点,所以可以认为这个簇表示新边,我们把表示的边的端点称为簇的端点(endpoint)。显然,这样就可以一直合并到只剩一个簇。

我们可以对树进行若干轮收缩,每轮收缩执行 rake 和 compress 的一个极大独立操作集合,这样 $O(\log n)$ 轮收缩后,树就只剩一条边了。最后这条边对应的簇称为根簇(root cluster)。

收缩过程中,簇的合并结构形成了一个树,我们把这个树称为 top tree。

一个簇对应着原树的一个路径和一个子树,其中路径为两个端点之间的路径。

关于边的信息储存在这条边对应的簇中,关于点的信息在消除这个点之后加入簇中(也就是说,簇内维护的信息,不包括端点上的信息),这在某种意义上类似于 BST。 类似序列上一些问题的做法,我们将会维护簇对应的路径和子树,以及两个端点相关的信息。

Contraction-based top tree

1998年 Holm 和 de Lichtenberg 在他们的硕士论文 Top-trees and dynamic graph algorithms 、2006年 Werneck 在 Design and Analysis of Data Structures for Dynamic Trees 给出了基于收缩的最坏 $O(\log n)$ 的实现。

如之前所说,我们对原树进行若干轮收缩,每轮收缩执行 rake 和 compress 的一个极大独立操作集合,这样可以得到 $O(\log n)$ 层的收缩结构。直接实现即可 $O(n)$ 时间建树。

关于这方面的操作的细节与更多介绍,也可以考虑看2019年国家集训队论文的《〈公园〉命题报告》与《〈基础圆方树练习题〉命题报告》。可是我还是觉得上面提到的那篇blog写得好。

尝试一下

当然,真正的Top Tree是一个支持link, cut, expose等操作的高级玩意儿,可惜我蠢得可爱,一时半会儿根本学不会。但是考虑到大部分题应该都不至于毒瘤到要你维护一个支持link,cut而且信息还不可减的动态DP,对于树的形态固定的题目,我们可以考虑使用静态Top Tree来维护我们的信息。下面的静态Top Tree实现是基于每轮执行极大R操作与C操作集合,进行 $O(\log n)$ 轮收缩的方法的。它本身常数可能比较大(当然应该比LCT小),但是其优势在于可以直观地进行一些DP信息的合并。

理论与思想部分上面已经讲得很清楚了。接下来我们直接上个例题,介绍如何使用Top Tree解决「SDOI2017」切树游戏。在看下面的内容之前,或许你可以先看看这篇immortalCO的题解,至少看到那个不支持修改的 $O((n+\log128)\times128)$ 的做法,了解朴素暴力的工作原理,并且知道如何用FWT优化信息合并中的异或卷积。

首先,我们不难设计出一个初步的想法。记 $f[i][0/1][0/1]$ 为一个多项式,它的第 $j$ 项系数代表编号为 $i$ 的簇,其头与尾的节点是否被包含在连通块内,异或权值为 $j$ 的方案数。合并两个块大致是在做一些异或卷积。

我们先不管具体的合并操作是什么样的,先建出Top Tree再说。考虑其实现。

建树

因为合并两个簇的时候经常要讨论两个簇的那个公共点的情况,所以我们把簇认为是有向的。为了避免不必要的翻转操作,我们可以对于每一条无向边建立正反两个簇,这样就可以保证每次合并都能找到方向正确的簇。在最后我们会得到两棵长得一样的树,不过两棵树上的对应点表示的簇互相是反过来的,我们任意选择一棵即可。

本来正经的实现方法应该是对于每个簇维护欧拉序循环链表,这样能支持link和cut啥的。但是我们不需要link和cut,所以我们每一轮顺序枚举所有点,遇到能够进行R操作或者C操作的就做。

每一轮初始的时候,所有点都是合法点,所有边(簇)都是合法边(簇)。

先讨论R操作的情况。假设我们得到了一个合法的可进行R操作的一度点。我们试图把这个一度点到它相邻点的边rake掉。设一度点为 $u$,它相邻点为 $v$。这里讨论把 $(u,v)$ rake掉的情况,对 $(v,u)$ 进行R操作是同理的。我们任意地指定一条关于 $v$ 的合法边 $(v,w)$,其中 $w\ne u$(如果不存在这样的边就跳过这个R操作),rake进去,删去 $(u,v)$ 和 $(v,w)$。此时我们应该会得到一个新的簇,它是原来两个簇的并,并且端点分别是 $v$ 和 $w$。我们把这个簇丢到一个tmp集合里,因为这个簇目前还不是合法簇。我们得保证这个新簇不会被作为本轮其余操作的对象。

注意到这样操作后,$u$ 点被从树中剥去了,而 $v$ 点和 $w$ 点由于旁边有一条本轮新建的边,所以既不能被rake掉也不能被compress掉,因为如果对它们执行rake或compress操作,就会导致这条边在这轮成为孩子,这是我们所不允许的。事实上,我们可以注意到,如果我们删边不即时修改点度,而是这一轮结束后再修改,那么所有的记录度数与实际边表大小不符的点都是不可rake或compress的点。而显然地,其它所有的一度点与二度点都分别是可rake与compress的。

C操作也是类似的。我们假设要缩掉的二度点是是 $u$,它两边的点是 $x$ 和 $y$,然后我们删去 $(x,u)$ 与 $(u,y)$,并且把簇 $(x,y)$ 丢进tmp集合里。

这样,一轮操作就做完了。我们把tmp集合里的边全部丟回e中,然后更新一下每个点的度数,就可以继续做下一轮了。具体实现可以参见代码。

信息合并

假设根簇是 $(s,t)$。那么我们从 $s$ 出发在原树上dfs,把原树变成有根树。对于每条边,我们令其权值为它的下面那个点的权值。这样我们就把除了根以外的所有点权转化为了边权。在合并的时候,讨论簇的公共点是否选择,可以得到rake操作与compress操作对答案的更新贡献方法。讨论的时候可以通过程序枚举合并类似的分类讨论,具体的方法可以参见代码。我自家还是认为这个讨论比动态DP的那些式子要易于思考一些。设根簇的编号为 $r$,最后我们的答案就是

其中 $w_s$ 是 $s$ 点对应的权值对应的FWT数组。

当然,这算是进行了一些trick式的把点权转化为边权。实际上,不进行这一步转换也是可以做的,如前文所提到的,用在一个点被消除的时候统计入答案的方法就好了。

代码

这份代码过了LOJ上的数据,但是不保证没有奇怪的错误,如果有的话务必把我拍醒w

以及树剖线段树的维护方法针对数据形态自带小常数,而这个做法的log是满的,所以不要指望一个log能比两个log快多少。不过这个做法比较稳定,经测在根号二叉树的数据下会比正常常数的树剖线段树略快。不知道有没啥方法卡慢。

以及我自带大常数体质,如果有啥更小常数的实现请务必考虑教教我qwq

#include<functional>
#include<algorithm>
#include<iterator>
#include<cstring>
#include<cassert>
#include<cstdio>
#include<map>
using namespace std;
const unsigned Ha=10007;
int m;
int fp(int b,int e=Ha-2)
{
	int r=1;
	for(;e;e>>=1,b=1ll*b*b%Ha)if(e&1)r=1ll*r*b%Ha;
	return r;
}
const unsigned rv128=fp(128);
inline unsigned sum(register unsigned a,register unsigned b){return a+b<Ha?a+b:a+b-Ha;}
inline unsigned dif(register unsigned a,register unsigned b){return a-b<Ha?a-b:a-b+Ha;}
struct poly
{
	unsigned a[133];
	void fwt()
	{
		for(int i=128;i>>=1;)
			for(int j=0;j<128;j+=i+i)
				for(int k=0;k<i;++k)
				{
					register unsigned x=a[j+k],y=a[j+k+i];
					a[j+k]=sum(x,y);
					a[j+k+i]=dif(x,y);
				}
	}
	void ifwt()
	{
		fwt();
		for(int i=0;i<128;++i)a[i]=a[i]*rv128%Ha;
	}
}cero,uno,f[133333][2][2],val[33333],res,QAQ[133];
inline void operator+=(poly&a,const poly&b)
{
	for(int i=0;i<128;++i)a.a[i]=sum(a.a[i],b.a[i]);
}
inline poly operator+(const poly&a,const poly&b)
{
	poly c;
	for(int i=0;i<128;++i)c.a[i]=sum(a.a[i],b.a[i]);
	return c;
}
inline poly operator*(const poly&a,const poly&b)
{
	poly c;
	for(int i=0;i<128;++i)c.a[i]=a.a[i]*b.a[i]%Ha;
	return c;
}
map<int,int>e[33333],tmp[33333];//If you want, you can use an unordered map instead.
int ce=1;
int ker[133333],ls[133333],rs[133333],pa[133333];
int deg[33333],rek[33333],mk[33333];
bool qwq[33333];
int rt,s,t;
bool dl[133333],dr[133333];
int comb(int a,int b,int c,bool detl,bool detr)
{
	int nv=++ce;
	dl[nv]=detl;
	dr[nv]=detr;
	pa[e[a][b]]=pa[e[b][c]]=nv;
	ls[nv]=e[a][b];
	rs[nv]=e[b][c];
	e[a].erase(b);
	e[b].erase(c);
	return nv;
}
int n;
void shrink()
{
	int dep=0,cp=n;
	if(cp==2)//Gotcha!
	{
		s=1;t=2;
		rt=e[1][2];
		goto heaven;
	}
	for(;dep<111;++dep)
	{
		for(int u=1;u<=n;++u)if(!qwq[u])
		{
			if(deg[u]==1&&e[u].size()==1)
			{
				int v=e[u].begin()->first;
				if(e[v].size()==1)goto hell;
				int w=e[v].begin()->first;
				if(w==u)w=(--e[v].end())->first;
				tmp[v][w]=comb(u,v,w,0,1);
				tmp[w][v]=comb(w,v,u,1,0);
				qwq[u]=1;
				++mk[v];
				--cp;
			}
			else if(deg[u]==2&&e[u].size()==2)
			{
				int x=e[u].begin()->first;
				int y=(--e[u].end())->first;
				tmp[x][y]=comb(x,u,y,1,1);
				tmp[y][x]=comb(y,u,x,1,1);
				qwq[u]=1;
				--cp;
			}
			hell:;
			if(cp==2)//Gotcha!
			{
				for(int u=1;u<=n;++u)if(tmp[u].size()==1)
				{
					int v=tmp[u].begin()->first;
					e[u][v]=tmp[u][v];
					s=u;t=v;
					rt=e[u][v];
				}
				goto heaven;
			}
		}
		for(int i=1;i<=n;++i)
		{
			for(map<int,int>::iterator it=tmp[i].begin();it!=tmp[i].end();++it)e[i][it->first]=it->second;
			deg[i]-=mk[i];
			mk[i]=0;
			tmp[i].clear();
		}
	}
	heaven:;
	assert(dep<=40);//If it's more than 40, you might have made some mistake.
}
void upd(int x)
{
	int l=ls[x],r=rs[x];
	f[x][0][0]=f[l][0][0]+f[r][0][0];
	if(!dr[x])f[x][0][0]+=f[r][0][1];
	if(!dl[x])f[x][0][0]+=f[l][1][0];
	f[x][0][1]=dr[x]?f[r][0][1]:cero;
	f[x][1][0]=dl[x]?f[l][1][0]:cero;
	f[x][1][1]=cero;
	#define foo(i,j) f[x][dl[x]?i:1][dr[x]?j:1]+=f[l][i][1]*f[r][1][j];
	foo(0,0);foo(0,1);foo(1,0);foo(1,1);
}
void extract(int x)
{
	if(ker[x])rek[ker[x]]=x;
	if(ls[x])
	{
		extract(ls[x]);
		extract(rs[x]);
		upd(x);
	}
}
struct R{int v,z;}ee[111111];
int K[33333];
void ins(int a,int b)
{
	ee[++ce]=(R){b,K[a]};
	K[a]=ce;
	e[a][b]=ce;
	++deg[a];
}
bool tr[111111];
void dfs(int u,int pa)
{
	for(int i=K[u];i;i=ee[i].z)
	{
		if(ee[i].v!=pa)dfs(ker[i]=ker[i^1]=ee[i].v,u);
		else tr[i]=1;
	}
}
int F()
{
	register int c,s;
	for(;c=getchar(),!isdigit(c););
	for(s=c-'0';c=getchar(),isdigit(c);)s=s*10+c-'0';
	return s;
}
int main()
{
	int heureuse=scanf("%d%d",&n,&m);
	for(int i=0;i<m;++i)
	{
		QAQ[i].a[i]=1;
		QAQ[i].fwt();
	}
	uno=QAQ[0];
	if(n==1)
	{
		int v;
		heureuse=scanf("%d",&v);
		int q;
		for(int i=scanf("%d",&q);i<=q;++i)
		{
			char op[11];
			int x;
			heureuse=scanf("%s%d",op,&x);
			if(op[0]=='Q')puts(x==v?"1":"0");
			else heureuse=scanf("%d",&v);
		}
		return 0;
	}
	for(int i=1;i<=n;++i)
		val[i]=QAQ[F()];
	for(int i=1;i<n;++i)
	{
		int a,b;
		heureuse=scanf("%d%d",&a,&b);
		ins(a,b);
		ins(b,a);
	}
	shrink();
	dfs(s,0);
	for(int i=2;i<2*n;++i)
	{
		f[i][0][0]=cero;
		f[i][tr[i]^1][tr[i]]=uno;
		f[i][tr[i]][tr[i]^1]=val[ker[i]];
		f[i][1][1]=val[ker[i]];
	}
	extract(rt);
	int q;
	heureuse=scanf("%d",&q);
	for(int i=1;i<=q;++i)
	{
		char op[11];
		heureuse=scanf("%s",op);
		int x=F();
		if(op[0]=='Q')
		{
			res=f[rt][0][0]+f[rt][0][1]+(f[rt][1][0]+f[rt][1][1])*val[s];
			res.ifwt();
			heureuse=printf("%u\n",res.a[x]);
		}
		else
		{
			val[x]=QAQ[F()];
			if(rek[x])
			{
				int r=rek[x];
				f[r][tr[r]][tr[r]^1]=val[x];
				f[r][1][1]=val[x];
				for(;(r=pa[r]);)upd(r);
			}
		}
	}
	int v=heureuse;
	return ~~(0*v*0);
}

外部链接

  1. negiizhao在UOJ博客上的《Top tree 的理论、用法和实现》
  2. immortalCO在UOJ博客上的《基于变换合并的树上动态 DP 的链分治算法 & SDOI2017 切树游戏(cut)解题报告》
  3. Wikipedia的Top Tree条目
  4. EtaoinWu在博客上的《Top Tree 学习笔记》
  5. 2019年国家集训队论文的《〈公园〉命题报告》与《〈基础圆方树练习题〉命题报告》
  6. 这些链接中提到的链接。其中有很多论文可以看,这里就不枚举了,因为我一篇都没看过。




这里还没有人评论过呀……