UOJ Logo dram的博客

博客

LibreOJ #6203 可持久化队列 题解

2017-07-13 09:02:45 By dram

参考文献


题解比较详细,请大家不要慌张。

记得用读入优化了么?

被 log 卡过了

dalao 的程序在此,$\log$ 碾 $O(1)$ 好妙啊

题目在此 标程在此

题目大意

维护一个可持久化的 std::queue 类似物,支持(可持久化的) push_backpop_front

各种记号的总结

记号 表示
$\mathtt{rev}(A)$ 列表 $A$ 反转
$\langle A=\dots\ \rvert\ B=\dots\ \rvert\ \dots\rangle$ 数据结构(struct 之类的)
$a_1\to a_2\to \dots\to a_n\to\mathtt{nil}$ 链表结构($\mathtt{nil}$ 就是表示空链表的假节点,指针 0 之类的)
$[a_1,a_2,\dots,a_n]$ 序列
$A+B$ 列表 $A$ 和 $B$ 相连
$\lvert A\rvert$ 列表 $A$ 的长度
$\mathtt{next}$ 链表指向下一个节点的指针
$\mathtt{value}$ 链表当前节点指向的值

列表和指向链表的指针不做区分

算法 1

我们可以用平衡树维护这个队列

时间复杂度 $O(T\log T)$

算法 m

我们可以在操作树上用倍增找到队首

时间复杂度 $O(T \log T)$ 竟然卡过了

dalao 的程序在此,$\log$ 碾 $O(1)$ 好妙啊

算法 x

对于没有加密的数据,我们可以离线构建出操作树来,这样只要遍历一遍就可以得到结果。

时间复杂度 $O(T)$

算法 2

我们维护两个链表 $L$ 和 $R$,那么整个队列就是 $L + \mathtt{rev}(R)$

比如 $\langle L=1\to2\to3\to\mathtt{nil}\ |\ R=6\to5\to4\to\mathtt{nil}\rangle$ 就表示 $\langle1,2,3,4,5,6\rangle$。

那么 $\mathtt{push\_back}$ 就是往 $R$ 前面加元素,$\mathtt{pop\_front}$ 就是从 L 前面拿元素。

比如这里 $\mathtt{pop\_front}$ 出来之后就会变成 $\langle L=2\to3\to\mathtt{nil}\ |\ R=6\to5\to4\to\mathtt{nil}\rangle$

$\mathtt{push\_back}(7)$ 就会变成 $\langle L=1\to2\to3\to\mathtt{nil}\ |\ R=7\to6\to5\to4\to\mathtt{nil}\rangle$

一看就可以可持久化对吧。今天的世界也很和平。

等下,如果 $L$ 直接就是 $\mathtt{nil}$ 怎么办?

凉拌。


我们来考虑一下,如果 $L​$ 是 $\mathtt{nil}​$ 的话,那么全队列就是 $\mathtt{rev}(R)​$。那么我们可以考虑先把 $L​$ 替换成 $\mathtt{rev}(R)​$,然后再 $\mathtt{pop\_front}​$。

如果 $R$ 也是 $\mathtt{nil}$ 怎么办?我不是说过不会出现 $\mathtt{pop\_front}$ 一个空队列的情况么?

那么比如 $\langle L=\mathtt{nil}\ |\ R=7\to6\to5\to4\to\mathtt{nil}\rangle$,我们就先把它变成 $\langle L=4\to5\to6\to7\to\mathtt{nil}\ |\ R=\mathtt{nil}\rangle$ ,然后开开心心地 $\mathtt{pop\_front}$ 出一个 $4$ 来就好了。

每个元素会被 $\mathtt{push\_back}$ 进来,被 $\mathtt{rev}$ 一次,再被 $\mathtt{pop\_front}$ 出来,所以是 $O(n)$ 的。

慢着,这个分析在可持久化的情况下是错的,因为……

我们不停地给 $\langle L=\mathtt{nil}\ |\ R=7\to6\to5\to4\to\mathtt{nil}\rangle$ 进行 $\mathtt{pop\_front}$,那么这几个元素就会一遍一遍地过 $\mathtt{rev}$,结果:$O(N^2)$

算法 3

APIO 2017 讲课的时候好像说过,我们可以把重构数据结构的时间真·平摊到单个操作上。那……把 $\mathtt{rev}(R)$ 摊到 $|R|$ 次操作上好了。

但是不太妙的是 $\mathtt{pop\_front}$ 的时候我们需要回答开头的元素啊。我们可以一般化重构的操作,不一定在 $L=\mathtt{nil}$ 的时候重构,而是可以提前:

$$ \langle L=A\ |\ R=B\rangle \Longrightarrow \langle L=A+\mathtt{rev}(B)\ |\ R=\mathtt{nil}\rangle $$ 如果 $|A|=n$ 而 $|B|=n+1$,(这么选取使得是为了把操作凑到正好,下面就明白了)那么我们把 $A+\mathtt{rev}(B)$ 的时间摊成 $n$ 份,用一种类似 lazy 标记的思路,在链表中除了这个节点的值 $\mathtt{value}$ 和下一个节点的指针 $\mathtt{next}$ 以外,再储存额外的信息 $\mathtt{r_1}$ 和 $\mathtt{r_2}$,用 $\langle \mathtt{value}\ |\ \mathtt{next}\ |\ \mathtt{r_1}\ |\ \mathtt{r_2}\rangle$ 表示我们这个链表其实是 $\mathtt{value} \to (\mathtt{next} + \mathtt{rev}(\mathtt{r_1})+\mathtt{r_2})$。注意到 $\mathtt{rev}(m\to X)+Y=\mathtt{rev}(X)+(m\to Y)$。(别忘了 $u\to [a_1,a_2,\dots,a_n]=[u,a_1,a_2,\dots,a_n]$)我们可以这么下发 lazy 标记:

  • ($\mathtt{next}\neq\mathtt{nil}$)$u\to ((v\to M)+\mathtt{rev}(z\to X)+Y) \Longrightarrow u\to v\to(M+\mathtt{rev}(X)+(z\to Y))$
  • ($\mathtt{next}=\mathtt{nil}$)$u\to(\mathtt{nil}+\mathtt{rev}(\mathtt{a\to b\to nil})+Y)\Longrightarrow u\to b \to a \to Y$

下发 lazy 的过程可以参照图片(图中标的数字,是这几个元素在完全发完 lazy 标记之后的相对顺序)

flandre.svg

不要忘了,我们创建 lazy 标记 $A+\mathtt{rev}(B)$ 的时候,有 $|A|=n$,$|B|=n+1$。如果 $A\neq \mathtt{nil}$。设 $A=u\to M$,则创建 $\langle u\ |\ \mathtt{next}=M\ |\ \mathtt{r_1}=B\ |\ \mathtt{r_2}=\mathtt{nil}\rangle$ 表示它。否则,$B$ 只有一个元素,$A+\mathtt{rev}(B)=B$。可以验证上面说的下发 lazy 标记的情况确实完整覆盖了所有可能。

注意:我们下发 lazy 标记的时候,其实确实是修改了之前版本的数据结构,但是我们并没有改变序列的内容,只改变了结构,所以没关系。

为了使得我们可以一边操作一边发 lazy,我们还需要在队列里维护一个指针 $\mathtt{cur}$ 表示我们的 lazy 发到哪儿了。

接下来,我们只要做到如下的事情,就可以成功维护队列了:

  • $|L|-|R|\geq0$,保证只要队列非空我们就可以直接从 $L$ 里面 $\mathtt{pop\_front}$ 出东西来,免得浪费时间算 $\mathtt{rev}(R)$。
  • 在 lazy 被发下去之前,我们不会去碰一个有 lazy 的节点

在这样的条件下,我们只要保证每次操作都是 $O(1)$ 的就保证了复杂度,这很简单。关键是怎么保证这两个条件。

我们发现,在不重构的情况下,$\mathtt{push\_back}$ 和 $\mathtt{pop\_front}$ 都会导致 $|L|-|R|$ 减小 $1$。而我们往 $\mathtt{next}$ 指针走的时候,$|\mathtt{cur}|$ 也会减小 $1$。于是我们可以直接保持 $\mathtt{cur} = |L| - |R|$。当 $\mathtt{cur} = \mathtt{nil}$ 的时候再操作,就导致 $|L|-|R|=-1$,正好该重构了。别忘了,我们的重构指的是: $$ \langle L=A\ |\ R=B\rangle \Longrightarrow \langle L=A+\mathtt{rev}(B)\ |\ R=\mathtt{nil}\rangle $$ 从版本 $v$ 生成版本 $i$ 的操作的时候:(设中间结果为 $i'$)

  • ($\mathtt{v\to cur=\mathtt{nil}}$)那么此时 $|v\to A|=|v\to B|$,操作完 $|i' \to A|-|i'\to B|=-1$,此时我们重构。最终得到的 $i\to A=i\to\mathtt{cur}= \langle i'\to A\to \mathtt{value}\ |\ \mathtt{next}=i'\to A\to \mathtt{next}\ |\ X=i'\to B\ |\ Y=\mathtt{nil}\rangle$,而 $i\to B=\mathtt{nil}$。
  • ($v\to \mathtt{cur}\neq \mathtt{nil}$)那么我们先给 $\mathtt{cur}$ 下发 lazy(要不然万一 $A$ 直接就是一个有 lazy 没发的节点就爆炸了),然后按照算法 2 操作。新的 $i\to \mathtt{cur}=v\to \mathtt{cur}\to\mathtt{next}$

这样我们连链表长度都不需要维护了,毕竟 $\mathtt{cur}$ 指针会告诉我们。

除了重构以外,其余可以参考算法 2 的前半部分。因为 $|L|-|R|\geq 0$,所以对于合法的操作一定不会遇到 $L=\mathtt{nil}$。

详见标程

时间复杂度 $O(T)$

算法 y

如果我们记录 $|L|$ 和 $|R|$ 来确定何时重构,并且不使用 $\mathtt{cur}$ 指针进行标记的提前下发,那么我们可能一次操作需要下发多重 lazy。但是可以证明每次操作仍然是均摊 $O(1)$ 的。

是的。

这是可持久化数据结构。均摊。

时间复杂度 $O(T)$


关于题图

肯定没有提醒你用两个链表表示队列,对吧

评论

l__ZeRo_t
这题可能只要倍增就好了啊,虽然一个log,但是更快更好写(捂脸 https://loj.ac/submission/17711
WrongAnswer
图好大……看不完整……

发表评论

可以用@mike来提到mike这个用户,mike会被高亮显示。如果你真的想打“@”这个字符,请用“@@”。