数据结构小记

吉司机线段树

众所周知普通的线段树用于维护区间加、区间乘、区间覆盖这些运算,我们认为这些运算一旦走到了线段树上对应的结点上,满足 L<=l&&r<=R,就一定可以执行更新操作或者 addtag

而吉司机线段树所维护的操作,就不是这么单纯的了。

思想

在普通的线段树上,更新或查询操作只受到一个条件的限制,就是结点对应的区间是否完全包含在要更新或查询的区间之内,那么我们 addtag 和返回查询结果的时候只需要考虑这一点就够了。

但是在吉司机线段树上,更新和查询操作不仅受到区间包含关系的限制,还受到要更新的值本身的限制。如果条件不满足,那么就一直向下递归,直到条件满足为止。

实例

以区间取 \(\min\) 操作为例。我们思考,我们递归到线段树上的某一个结点时,如果其满足 L<=l&&r<=R,是否就直接可以更新 \(\min\) 值了呢?不行的,无法维护,否则空间过大。

上面说到,“要受到要更新的值本身的限制”。那么我们判断条件就应该是长成这个样子:L<=l&&r<=R&&val...。一个显然正确的做法就是,记录每个结点的最大值 \(mx\),次大值 \(se\),最大值的个数 \(cnt\),如果 \(val\ge mx\),那么当前区间无需更新,如果 \(mx>val>se\),那么当前区间的 \(sum\) 加上 \(cnt\times(val-mx)\),并将 \(mx\) 更新为 \(val\)。这是一个常数巨大的 \(O(n\log n)\) 算法。

除此之外,吉司机线段树的另外一个好处,就是无论我们怎么给它叠操作,它都能应付下来,剩下的只是常数问题。这里就出现了一个分析问题的方式:我们要支持操作,那么我们只需要加几个变量来维护这个操作就行了,剩下的都是常数问题,就是所谓的要什么用什么。

比如,给定区间 \([l,r]\),我们现在要支持以下五个操作:

  1. 1 l r k:对于所有的 \(i\in[l,r]\),将 \(A_i\) 加上 \(k\)\(k\) 可以为负数)。
  2. 2 l r v:对于所有的 \(i\in[l,r]\),将 \(A_i\) 变成 \(\min(A_i,v)\)
  3. 3 l r:求 \(\sum_{i=l}^{r}A_i\)
  4. 4 l r:对于所有的 \(i\in[l,r]\),求 \(A_i\) 的最大值。
  5. 5 l r:对于所有的 \(i\in[l,r]\),求 \(A_i\) 的历史最大值。

那么我们线段树上每个结点就要维护这样的信息:区间和,区间最大值,区间次大值,区间最大值的个数,给区间最大值的 \(addtag\),给区间非最大值的 \(addtag\),以上除区间次大值和区间最大值个数和区间和意外所有变量的历史最大值。

可以发现,由于有了历史查询,我们给很多变量附了一个其历史最大值,这就是要什么用什么的集中体现。

如果我们要取 \(\max\),那么我们就记录 \(mn,semn,cntmn\);如果我们要查询历史最小值,那么我们就给大部分变量附一个历史最小值;如果我们要区间覆盖,那么我们就加一个 \(covtag\)……(其实没必要,\(\text{cover}(x)=\max(x)\&\min(x)\))反正什么我们都可以从容应付。

李超线段树

我们说一个关系是“线性”的,就是说它长得像线 \(y=kx+b\) 一样,画出来是一条直线。但是如果我们要维护很多这样的“线性”关系,就必须要一个数据结构。这个数据结构就是李超线段树。

思想

首先我们要说明一个很重要的点:李超线段树是不存在 pushdown 这个操作的。一旦李超线段树的区间更新走到了某个满足 L<=l&&r<=R 的区间,那么其会立刻加标记并立刻把标记下放,所以李超线段树的时间复杂度是 \(O(n\log^2 n)\) 的。

然后我们开始研究李超线段树的 \(addtag\) 操作,会发现其似乎和吉司机线段树有点相似。我们以取最大值为例。首先,每个结点必然存储了某个原来在最上面、完全覆盖掉了所有其他线段的线段,或者这个结点根本就没有这个线段。我们把接下来的情况分为三种:

  1. 新来的线段完全覆盖了原来最上面的线段。
  2. 原来最上面的线段完全覆盖了新来的线段。
  3. 原来最上面的线段与新来的线段有交。

前两种情况易于处理,第三种情况难以处理。如果遇到了第三种情况,那就继续下放标记,直到找到某个区间使得前两种情况满足一种为止。

欸,我们来看吉司机线段树的区间取 \(\min\) 操作。我们把在线段树上某个结点遇到的情况分为三种:

  1. \(mx>val>se\)
  2. \(val\ge mx\)
  3. \(val\le se\)

遇到第一种情况,直接更新后 return,遇到第二种情况,直接不更新 return,遇到第三种情况则继续往下递归。

由此我们可以得到一个暴论:李超线段树就是吉司机线段树的线段版。

实例

李超线段树会有很多种运用,这里只取一道模板题

会发现大体思路和上面说的差不多,有一个实现细节要说。

我们上面说,用 tr[rt] 表示在区间最上面的线段。但是,如果没有在最上面的线段呢?很多线段互相相交,形成一个凸包呢?这里我们取区间 \(\text{mid}\) 值来计算出最上面的线段。那这就存在一个问题:如果我们用区间 \(\text{mid}\) 计算出,某条线完全覆盖了某个区间,那么我们的 addtag 到此为止,不会再往下传。那要是某天我们查到了子区间呢?

我们查子区间的过程,肯定是一路从上往下找下来,再从下往上传上去。我们传上去的过程中,每一个子区间的父区间必然被访问,我们只需要用子区间记录的线段代入 \(x\) 的答案跟父区间记录的线段代入 \(x\) 的答案取 \(\max\) 就可以了。

平衡树系列

首先平衡树是线段树的完全上位替代(除了时间)。

平衡树能够随时拆出一个区间(\(\texttt{Treap}\) 不行),并对其执行区间翻转等一系列线段树无法完成的操作(\(\texttt{Treap}\) 不行),所以平衡树是线段树的完全上位替代。

思想

首先我们来说平衡树的区间操作思想。

我们上面说到平衡树是线段树的完全替代,因为平衡树能随时把一个区间转化成一棵树,我们只需要在树根进行 \(addtag\),然后把这棵拆出来的树合回去。

于是最直观最暴力的 \(\texttt{FHQ Treap}\) 诞生了。比如,原来维护一个序列的平衡树像这样:

我们从中拆出 \([4,8]\) 这个区间之后,它可能就变成了这样:

bst.png

因此说它最直观最暴力,因为它真的把代表对应区间的树“拆”出来了!由此就可以知道,\(\texttt{FHQ Treap}\) 常数其实是很大的,不推荐在一些轻微卡常题中使用,连轻微都不行!

三种常见平衡树 \(\texttt{Treap,FHQ Treap,Splay}\) 中,当然是 \(\texttt{Treap}\) 常数最小(废话,它连区间操作都不支持),支持区间操作且常数较小的只有 \(\texttt{Splay}\) 了。

\(\texttt{Splay}\) 是一种旋式平衡树,已经可以想到,它能做的就是旋旋旋。我们说 \(\texttt{Splay}\) 的旋转操作不说“左旋”或“右旋”,而是说“向父亲旋”。\(\texttt{Splay}\) 也不需要额外记录一个值来维护 \(\texttt{Heap}\) 性质(所以它不叫 \(\texttt{Treap}\)),所以我们就大概知道 \(\texttt{Splay}\) 要怎么区间操作了。

假设我们要操作的区间为 \([l,r]\),那我就把 \(l-1\) 旋旋旋,旋到根处来,再把 \(r+1\) 旋旋旋,旋到根的右儿子处来,那么这个时候 \(r+1\) 的左儿子对应的子树就是区间 \([l,r]\)。不直观,但也不暴力,是较为优雅的一种区间操作方式。

现在来批判一下 \(\texttt{Treap}\) 为什么不支持区间操作,或不支持线段树以外的其他区间操作。因为它既不能拆拆拆(\(\texttt{Treap}\) 是旋式平衡树),又不能旋旋旋(任意旋转将会破坏 \(\texttt{Heap}\) 性质),它当然不能支持线段树以外的区间操作了!这就是原因。

然后我们来说平衡树的平衡维持思想。

平衡维持分为两种,一种是期望平衡(通过自身的调整和随机数这些玄学因素保持树本身的平衡,属于自我修养),一种是均摊平衡(外界每次查询都会改变树的形态,均摊下来时间复杂度是 \(n \log n\) 的,属于外界鞭策)。

\(\texttt{Splay}\) 是一种均摊平衡的树。具体而言,外界每查询到一个点,就把这个点旋到根处,这样做在很多情况下都是 \(O(n\log n)\) 的,但是一旦我们考虑到,如果树是一条链,那么它再怎么旋,都还是一条链,怎么办呢?

\(\texttt{Splay}\) 采用双旋策略,作用是把一条链长度折半。

\(\texttt{Splay}\) 的双旋,如果一个点 \(u\) 与其父 \(fa\) 这两个点,分别与自己父亲的相对位置是相同的,那么先 \(\text{rotate}(fa)\),再 \(\text{rotate}(u)\)

如果 \(u\)\(fa\) 这两个点,分别与自己父亲的相对位置不同,那么就把 \(u\) 旋转两次。这就是 \(\texttt{Splay}\) 的双旋平衡策略。

实例

\(\texttt{Splay}\) 的所有核心操作都在上面,而其应用很多。

除了我们刚才说到的区间操作,\(\texttt{Splay}\) 还是 \(\texttt{Link-Cut Tree}\) 的最好辅助平衡树,以及树链剖分上一些线段树维护不了的情况的优秀辅助平衡树。这是由于 \(\texttt{Splay}\) 有一个很特殊的操作:旋转到根。这不仅是一个平衡策略,还可以满足特殊题目的特殊需要(比如 \(\texttt{Link-Cut Tree}\))。

那么 \(\texttt{FHQ Treap}\) 有什么优势呢?\(\texttt{FHQ Treap}\) 是唯一无旋平衡树,这就是说它可以做可持久化。而且 \(\texttt{FHQ Treap}\) 与左偏树合并方式异曲同工。

树链剖分

树链剖分,把树拆成链之后来解决链上问题。

思想

树链剖分就是修改树上某一条链的信息,通常是区间修改区间查询。

首先我们来说一说怎么个剖分法,怎么个把树拆成链。

把树拆成链最经典的思想之一,就是重链剖分,定义每一个点的重儿子为儿子中子树大小最大的那个,把重儿子和链顶形成的链叫做重链。可以证明,我们想要从任意一个点通过一直跳链顶的方式跳到根,不会跳超过 \(O(\log n)\) 次。如果我们要更新一条链,同样的,只会有 \(O(\log n)\) 个区间被更新,总复杂度大约是 \(O(\log^2 n)\)

然后你再细品,再细品,你就会发现这玩意复杂度大概只比 \(O(\log n)\) 大一点,因为被更新的 \(O(\log n)\) 个区间长度都是很小的。

这是树链剖分的原理。接下来,上面讲了那么大一堆平衡树可以替换线段树,搁这终于有用了。

我们想,树链剖分这东西吧,肯定是一堆区间操作,那碰上区间翻转这种区间操作,不仅要用平衡树维护,还要把很多很多的平衡树合起来,超级麻烦,每条链都要记录是哪一部分被合了,方便合回来,这就是树链剖分的一个大弊端,如果一不小心写错了,毫无可调性。

树链剖分的区间修改是基于 \(\text{DFS}\) 序的,每一条链上的 \(\texttt{DFN}\) 是挨在一起的,并且每一棵子树的 \(\texttt{DFN}\) 也是挨在一起的,所以树链剖分可以在支持链操作的同时支持子树操作。

实例

树链剖分的经典形式是:链操作,子树操作。注意如果只是链操作,还遇上了那种必须合平衡树的问题,那么树链剖分会很不好写且很不可调。所以树链剖分的经典形式一般必须带一个子树操作。

\(\texttt{Link-Cut Tree}\)

为什么我们上面说树链剖分的经典形式一般要带一个子树操作?这就是如果只有链操作,那么就是 \(\texttt{LCT}\) 的主场了。

思想

我们从 \(\texttt{Link-Cut Tree}\) 的功能说起。

首先,\(\texttt{Link-Cut Tree}\) 顾名思义,是支持连边断边的一棵树。

树链剖分的区间修改基于 \(\text{DFS}\) 序,所以能同时支持子树操作和链操作。但是支持连边断边的树在多次操作之后,\(\text{DFS}\) 序会被完全打乱,所以 \(\texttt{Link-Cut Tree}\) 的链修改当然不能是基于 \(\text{DFS}\) 序的了。那么这也就是说,\(\texttt{Link-Cut Tree}\) 不能支持子树操作。

\(\texttt{LCT}\) 是支持链操作的,接下来来说怎么支持链操作。

首先,\(\texttt{LCT}\) 的维护链操作方式虽然异于重链剖分,它也是需要剖分来保持任意一个点到根的路径都只有 \(O(\log n)\) 条链的。那么我们从链的剖分方式说起。

\(\texttt{LCT}\) 的剖分方式被称为“实链剖分”,每个点最多有一个实儿子(这就是说可以没有),每个实链用一棵 \(\texttt{Splay}\) 维护,就是平衡树维护链操作。\(\texttt{LCT}\) 保持每个点到根的路径只有 \(O(\log n)\) 条链的方式是均摊法。除去 \(\texttt{Splay}\) 原有的核心操作以外,\(\texttt{LCT}\) 还有一个核心操作,就是 \(\texttt{access}\) 操作。

\(\text{access}(u)\) 操作的作用是把从根到 \(u\) 的路径变成一条实链,并把连向这条实链的所有边都变成虚边。这个操作就是使 \(\texttt{LCT}\) 均摊复杂度得到保证的核心了。为什么?我们还要讲讲以 \(\texttt{access}\) 操作为核心的一些 \(\texttt{LCT}\) 基本操作。

第一个是 \(\text{makeroot}(u)\),作用是把 \(u\) 变成树的根。那么我们就先执行 \(\text{access}(u)\),再执行 \(\text{splay}(u)\),这个时候 \(u\) 就已经是树的根了,但是由于 \(\texttt{Splay}\) 树中存储的结点,其中序遍历应当是从根到叶的,在把原来的根换到不是根的位置之后,要把整个 \(\texttt{Splay}\) 树反过来。这样我们连成了一条实链,降低了一些复杂度。

第二个是 \(\text{getroot}(u)\),作用是找到 \(u\) 所在子树的根。其也要先执行 \(\text{access}(u)\),然后在 \(u\) 所在 \(\texttt{Splay}\) 上一直往左走,最后找到的点就是根。

你会发现,这些操作都是离不开 \(\texttt{access}\) 的,如果一切操作都围绕 \(\texttt{access}\),那么就会有很多实链出现,一次查询所跳的次数越多,就是说这条路径上虚链越多,那么本次 \(\texttt{access}\) 操作所消去的虚链越多,那么下一次虚链就会更少。

上面的操作就是核心操作和其他基本操作了。接下来的 \(\texttt{link}\)\(\texttt{cut}\)、提取路径三个操作就及其简单了。

\(\text{split}(u,v)\):提取一条 \(u\rightarrow v\) 的路径。那么只要 \(\text{makeroot}(u)\rightarrow\text{access}(v)\) 就可以直接提取这条路径了。由于 \(\texttt{Splay}\) 每访问到一个结点都要把它旋转到根,所以:

\[\text{split}(u,v)=\text{makeroot}(u)\rightarrow\text{access}(v)\rightarrow\text{splay(v)} \]

\(\text{link}(u,v)\):连接 \(u,v\) 两个点。我们先把 \(u\) 变成根,再查询是否 \(\text{getroot}(v)=u\),如果不满足,那么 \(fa_v\leftarrow u\)

\(\text{cut}(u,v)\):断开 \(u,v\) 两个点之间的边。这里只要先 \(\text{makeroot}(u)\) 再判断是否 \(fa_v=u\) 就行了。

那么通过观察,我们发现我们甚至完全不用把原来的树记录下来,直接在 \(\texttt{Splay}\) 上完成所有操作就行了。既然操作围绕 \(\texttt{Splay}\) 展开,而原树上可能一个结点有多个儿子,这种情况如何解决?

在上面的所有操作中,只有从下往上连成实链的,没有从上往下找什么东西的,这样就可以用“认父不认子”法,\(fa_v=u\),但是 \(v\notin son_u\)。这样写代码会方便很多,尤其是在断开实边变成虚边的时候。

实例

我们在上面树链剖分的时候说了一句,树链剖分的经典形式一般要带子树操作。这是因为 \(\texttt{LCT}\) 本身就是为链操作而生的,其链操作码量会非常小,细节也是几乎没有,只需要执行一次 \(\text{split}(u,v)\),就可以拆出 \(u\rightarrow v\) 这条链,而 \(\texttt{LCT}\) 全部的码量可能比不上树链剖分暴力拆链再合回去一个函数的码量。这是因为树链剖分按照一定的规则剖分,剖出来的链形态是一定的,如果链形态变化,则复杂度错误。但是 \(\texttt{LCT}\) 采用均摊方式,链形态可以任意变化,所以其维护链操作十分优秀。就像 \(\texttt{Treap}\) 不能维护区间操作,一旋就废;但是 \(\texttt{Splay}\) 可以任意旋,因为它本来就是个均摊。

posted @ 2025-02-17 19:35  KarmaticEnding  阅读(33)  评论(0)    收藏  举报