【学习笔记】树论

树链剖分

树上差分

点差:P3128 Max Flow P

边差:P3038 Grass Planting G

对于普通的序列差分,最后进行一次 \(dif_i=dif_i+dif_{i-1}\) 的操作使得所有点变回原来的样子。

但是树上差分不能这么干。因为我们进行普通差分时会让 \(dif_{r+1}\leftarrow dif_{r+1}-1\)。而在树上一个点可能有许多儿子,全部进行一次操作代码就很难搞,而且会被菊花图卡。

分析一下出问题的原因出在树上一个点可以有多个儿子。从而我们会想到在树上一个点还是只有一个祖先。于是我们再想到树上非常容易出现的递归性,就能想到最后进行的应该是 \(dif_i=\sum dif_e(i\ 是\ e\ 祖先)\)

所以对于 \(dif_i\leftarrow dif_i+1\)。根据最后的求值,我们可以理解为从根到 \(i\) 的所有点加上 \(1\)。而我们只要从 \(e\)\(i\) 的所有点加上 \(1\)。于是我们让 \(dif_{fa(e)}\leftarrow dif_{fa(e)}-1\)

对于边差分就考虑把边下放到点。对于一个边 \((x,y)\),应该下放到深度更深的点,因为这个点只有一个父节点,从而代表只有这一条边连向了深度更浅的点。

那么这样的两条链应该拆成 \((l,x)\)\((l,y)\)。操作就好。

注意点差分边差分修改的位置不同。

树上启发式合并(dsu on tree)

当题目中没有修改并且查询仅与子树有关可以考虑通过树上启发式合并来做到 \(O(n\log n)\)。实质是把树上的包含关系利用起来,通过继承数组减小撤销贡献带来的时间开销。

定义重儿子为子树中节点最多的儿子。轻儿子是除重儿子外的儿子。一个点连向重儿子的边叫做重边,其他的叫做轻边。

考虑这样一个暴力:每次把一个子树的答案算出来,然后清空所有数组。显然复杂度是 \(O(n^2)\) 的。

考虑优化这个暴力。我们发现对于子树 \(x\),最后一个计算的儿子的贡献不需要清空,因为自己反正还要用一遍。

那么我们可以考虑把最大的儿子(重儿子)留下来不删除。也就是在计算当前点的时候,先把轻儿子答案计算完,并且计算完就删掉防止和其他儿子产生冲突,然后计算重儿子答案并保留贡献,再把所有轻儿子贡献加回来得到当前答案。

实现的话我们给函数多加上一个布尔值代表这一次的数据需不需要保留。然后后面给轻儿子加贡献就写一个新的函数。

复杂度是 \(O(n\log n)\)

证明:

  • 考虑一个点会被计算的次数。如果它位于 \(f\) 的重儿子中它的贡献会被传上去,那么一直是统计一遍。然后如果碰到轻边会重新计算一次,也就是说一个点被计算的次数是它到根的轻边数加一。

  • 考虑一条边是轻边,那么节点数至少乘以 \(2\),所以轻边数是 \(\log n\) 的。所以复杂度 \(O(n\log n)\)

重链剖分

重链是连续的重边连成的链。

根据上面的性质我们知道重链的断开点是轻边,一共只有 \(\log n\) 个轻边,所以重链数量级是 \(\log n\) 的,而且大多数情况下搞不满。

根据这个性质,对于重链剖分,它能维护很多东西。前提是 dfs 序,在 dfs 时优先访问重儿子能够保证重链 dfs 序连续,然后子树 dfs 序也连续,所以它能配合很多可爱的数据结构进行很多可爱的算法。

针对子树的操作很简单,对于路径操作,我们维护当前点所在的重链顶,然后找两个端点里面深度最深的点往上面跳直到位于同一个重链(具体原因忘了,有博客讲过),过程中根据数据结构更新。

长链剖分

类似重链剖分。把子树中深度最大的点作为重儿子。一个点到根节点的重边切换次数是 \(O(\sqrt n)\) 级别的。不会证,反正长剖用处不大。

可以 \(O(n\log n)-O(1)\)k 级祖先。做法挺好玩的,自己看题解,但是常数过大不如重剖。

最大的用处在于优化 某些 dp,但是由于涉及指针,写起来非常难受。

针对链接那道题记录一下一般长剖优化 dp 的做法吧。

快进到 \(f_{i,j}\) 表示子树 \(i\) 中离 \(i\) 距离为 \(j\) 的点的个数,\(g_{i,j}\) 表示子树 \(i\) 中合法的无序点对 \((x,y)\) 个数,满足令 \(c=lca(x,y)\),有 \(dis(x,c)=dis(y,c)=dis(i,c)+j\),即 \(i\to c\) 补上一个长度为 \(j\) 的链后有 \((i,x,y)\) 为合法三元组。

对于节点 \(p\) 的贡献:

  • \(ans\leftarrow ans+g_{i,0}+\sum_{x,y\in son(p),x\ne y}f_{x,j-1}\times g_{y,j+1}\)

  • \(g_{p,j}=\sum_{x\in son(p)}g_{p,j+1}+\sum_{x,y\in son(p),x\ne y}f_{x,j-1}\times g_{y,j-1}\)

  • \(f_{p,j}=\sum_{x\in son(p)}f_{x,j-1}\)

注意到其他维度只跟深度有关,可以考虑长剖优化。

对于重儿子发现直接继承祖先的 \(f,g\) 即可,具体地,若 \(q\) 的父亲是 \(q\),则 \(f_q\) 即为 \(f_p\) 左移一位,\(g_q\) 即为 \(f_q\) 右移一位。这里用指针实现,将 dp 值存在一个数组然后用指针访问得到左移右移的继承效果。轻儿子暴力转移,加上一个前缀和可以做到线性。

如果没有 \(g\) 数组而仅考虑 \(f\) 数组,深度最大为长链长度 \(d\),于是只需要申请 \(d\) 的空间,而这之后长链上的点都会继承这个空间,所以总空间开销为点数 \(n\)。加上 \(g\) 数组后由于左移需要给 \(g\) 左边预留 \(d\) 的空间,\(g\) 自身也有 \(d\) 的大小,空间开销变成 \(3n\) 左右。注意边界的处理,\(f_{p,d},g_{p,d}\) 都为 \(0\),我们可以在取这里的时候特判或者多预留一点空间,防止某些地方的 \(f,g\) 重叠影响答案。

观察转移发现一个点的时间开销是其轻儿子长链长度和,这个东西和空间复杂度基本相同,所以时间均摊下来是 \(O(n)\) 的。

树分治

点分治

普通的分治在一段子段 \([l,r]\) 中处理和 \(mid\) 有关的信息然后递归处理 \([l,mid)\)\((mid,r]\)。由于中点的优秀性质这种看似暴力的做法实际复杂度是 \(O(n\log n)\) 的。

点分治是一种把分治思想运用到树上解决问题的算法(但是其实更多人愿意称其为数据结构?)。它一般适用于与路径有关的问题。

考虑我们现在处理的是子树 \(p\),仅仅根据子节点很显然会导致分治复杂度出错。我们希望寻找一种递归方式使得递归层数尽量地小。联想到以树的重心为根每个子树大小不超过其一半的性质,每次找到一个连通块的重心,处理经过这个重心的答案,然后把这个重心删去递归其子树。

注意我们处理的是这个重心的答案,于是我们要减去来自同一子树的贡献。具体的做法有很多种。你可以把 加贡献和处理答案分开做,对于一个子树先把它的答案统计完再加入其贡献。这种写法常数略大。

你也可以 记录一个点属于哪个子树然后离线下来做,在本身就需要离线处理贡献的题目里面很好用。

如果记录一个节点属于哪个子树但是不好维护其在那个子树中的贡献也可以把答案统计完后 用类似容斥的做法 把来自同一个子树的贡献减去。

点分树

尝试把每层找到的重心和上一层的重心连边形成一棵点分树。

有一些优秀的性质。

首先树高是 \(\log n\) 的。因为点分治只会进行 \(\log n\) 层。这使得暴力跳树有了正确性。

然后所有节点的子树大小和是 \(O(n\log n)\) 级的,因为每个重心会访问其子树大小个点,所以复杂度延续点分治的分析。

然后点分树上 \(x,y\) 两点的 lca 一定是原树上 \(x,y\) 路径上的一个点。其他的点可能都没有关系。

所以如果我们的答案只要知道路径上任意一个点而不是硬性要求 lca,就可以暴力跳点分树求解。单论点分树复杂度一次是 \(O(\log n)\) 的。

虚树

给你 \(k\) 个关键点 \(a_1,a_2,\cdots,a_k\),让你在原树上保留这些关键点以及它们的 lca 建出来虚树。目前主流有两种做法。

第一种做法是 按照 dfs 序排序\(k\) 个点然后求出 \(lca(a_1,a_2),lca(a_2,a_3),\cdots,lca(a_{k-1},a_k)\)

然后对这 \(2k-1\) 个数去重并再次按照 dfs 序排序,连接 \((lca(a_1,a_2),a_2),(lca(a_2,a_3),a_3),\cdots,(lca(a_{k-1},a_k),a_k)\)

首先证明第二次求 lca 时求得的 lca 都已经在此时的 \(a\) 序列中出现过:

  • 按照是否是给出的关键点将 \(a\) 序列的点分成关键点和非关键点。令第二次求 lca 时求得 \(c\)\(x,y\) 的 lca。

  • \(x,y\) 有祖先后代关系那根本不用证了,\(lca(x,y)\) 就是深度更浅的那个点,一个点本身肯定在序列里。于是令 \(x,y\) 不是祖先后代关系。

  • \(x,y\) 均为关键点,根据 dfs 序的排序规则它们在第一次排序时也一定相邻,于是我们在第一次排序时已经将 \(c\) 加入了序列。

  • \(x\) 为关键点,\(y\) 非关键点(反之亦然),则 \(y\) 子树内一定还有至少多于一个关键点,否则 \(y\) 不可能被加入序列。令其子树中 dfs 序最小的点为 \(p\)。观察到按照 dfs 序排序后一个点的子树一定是直接接在在它后面的一个连续段,所以第一次排序后一定有 \(x,p\) 相邻,而 \(x\) 并不在 \(y\) 子树内,所以 \(lca(x,p)\) 一定是 \(y\) 的祖先,则 \(c\) 一定就是 \(lca(x,p)\)

  • \(x,y\) 均非关键点,注意到按照上一条证明,若 \(x,y\) 无祖先后代关系,此情况下则 \(x\) 的子树为空,但是 \(x\) 非关键点,于是 \(x\) 根本不可能被加入序列。得到此种情况不会发生。

好的那么注意到我们对新的序列(令长度为 \(m\))中的 \(m\) 个点连了 \(m-1\) 条边,同时由于两个点的 lca 的 dfs 序一定大于这两个点的 dfs 序,于是我们不可能会连出重边或者自环,于是至少我们得到的是一棵树型结构。

然后感性理解一下这个东西应该是能把所有 lca 搞出来的。

然后因为 dfs 序的性质我们连 \((lca(x,y),y)\) 时,\(lca(x,y)\)\(y\) 这段一定没有 \(a\) 序列上的点,因为这一段的 dfs 序小于 \(y\) 的 dfs 序。所以说我们不会连错边。那么虚树的正确性应该就能证完了吧?

观察到求 \(m-1\) 次 lca 最多增加 \(m-1\) 个新节点,于是可以证明出来虚树的点数上界是 \(2m-1\)。实际写的时候可以把原树的根节点先塞到虚树里面。

第二种做法是用 单调栈维护右链,感觉和笛卡尔树差不多的想法?

就是你维护最右边那条链,每次加入一个关键点的时候跟栈顶做一个 lca,如果不是栈顶就弹栈弹到栈顶变成那个 lca 或者是那个 lca 的祖先。在弹出的时候连边即可。

在原树节点数本身较少时可以 暴力建树,先统计含有关键点的不同子树个数标记虚树上节点,然后连接祖先后代关系的两个节点。不过基本用不上就是了。

Prüfer 序列

对于一个 \(n\) 个节点的无根树,可以按照以下方法将其转化为一个序列:若剩余节点数量 \(>2\),删去编号最小的叶子即度数为 \(1\) 的节点填入序列。

此时会得到一个 \(n-2\) 的序列 \(a\),我们称这个序列为这棵树的 Prüfer 序列。注意到剩下的两个数中必定有一个是 \(n\),因为 \(n\) 永远不会作为编号最小点。

线性求 Prüfer 序列并不困难,维护最小叶子指针 \(p\),每次删除后最多增加一个叶子,此时特判若该叶子编号小于 \(p\) 则优先使用该叶子即可做到指针单调不减从而实现线性。

将 Prüfer 序列转化回原树也是类似的。很容易发现 Prüfer 序列中 \(i\) 的度数即为 \(i\) 的出现次数 \(+1\)。所以我们很容易得到原树上每个点的度数和编号最小的叶结点,而这个结点一定与 Prüfer 序列的第一个数连接。同时删掉这两个结点的度数重复进行操作即可得到原树。也可以通过维护指针做到线性,不再赘述。

真正使用 Prüfer 序列的题似乎不多?但是我们发现 Prüfer 序列与原树构成了一对双射,且任意一个值域在 \([1,n]\) 的数列都可作为一个合法的 Prüfer 序列,所以我们可以证明无向完全图无根生成树的个数为 \(n^{n-2}\),同时可以通过随机 Prüfer 造数据,据说此时期望的树高和随机父亲的树高不同,但是我忘了。

posted @ 2024-03-02 07:09  Wind_Leaves_ShaDow  阅读(23)  评论(0)    收藏  举报