一类撤销问题

Published: by Creative Commons Licence

  • Tags:
  • Table

撤销的实现方式

如果一个操作对整体的改动很小,那么我们可以记录这个操作执行后的修改,并记录在案,之后就可以很方便的撤销这些修改。

比如所我们某个操作将x改变为x+1,那么我们只需要记录这个操作信息,之后在撤销的时候将x改变为x1即可。但是不是所有操作都可以通过仅记录操作信息就能撤销的,比如chmin操作,将某个变量x修改为min(x,5),这样我们就不能简单的撤销这样的操作了。更加好用的方式是记录变量被修改前的状态,比如x=x0,这样就能保证对任何操作都能执行撤销。

栈式撤销

撤销操作一般都需要栈式操作,即先完成的操作后撤销。这是非常重要的,因为后完成的操作可能依赖于先完成的操作带来的修改。比如创建对象,我们先创建一个对象A,之后创建一个对象B,并且B持有A。如果我们先撤销创建A这个操作,即销毁A,那么会导致销毁B的时候A已经不可用了。

栈式撤销是非常自然的,比如在树上进行DFS操作。于是在操作的时候我们每次函数返回后,所有的局部变量都会被销毁,而调用者的变量会恢复。

队列撤销

这篇内容基本来自[Tutorial] Supporting Queue-like Undoing on DS这篇文章,这里只是翻译和自己润色一下。

我们知道最自然的撤销方式是栈式的,但是如果题目要求你先撤销最旧的操作怎么办。这样的问题还是存在的,比如给定n个顶点,以及m条无向边,以及某个数k,要求为所有连续的k条边,计算在仅考虑这k条边的情况下有多少不同的连通分量。当然这个问题可以通过线段树+并查集在O(nlog2nlog2m)的时间复杂度内完成,也可以通过LCT在O((n+m)log2(n+m))的时间复杂度内解决。但是它们都是离线算法,我们接下来要讲一个可以泛化的在线算法,可以在O(nlog2nlog2m)的时间复杂度内解决这个问题。

我们先回到队列撤销这个话题中。首先我们很多时候只能撤销最近的操作,原因已经在栈式撤销一章中讲的很详细了,因此如果强行用队列撤销,那么就很难通用。

那么我们是否可以仅通过栈式撤销来实现队列撤销呢?如果操作满足交换性(即操作的执行顺序不影响结果),那么答案是可以的。下面讲一下做法。

考虑一种情况,所有操作都执行完成后,我们再逐一执行撤销。我们可以将所有操作存在栈中,之后我们出栈再重新入栈,这样就能翻转栈中的所有元素。这时候出栈就等价于出队列。

当然不是所有情况下都这么完美的,大部分时候执行操作和撤销操作是交错执行的。当然我们可以复用上面的思路,每次撤销之前,将栈中元素全部弹出,并重新排序后插回栈中,但是这会导致操作的执行次数和撤销次数达到O(n2)。这太慢了。考虑上面过程中我们实际上栈中维护的是两种元素,一种是翻转过的,一种是没翻转过的,所有翻转过的元素都在没翻转过的元素之前执行。我们将前者记做A类操作,后者记做B类操作。下面我们讲一个更加快的算法。

  • 对于执行一个新操作,我们将操作作为B类操作入栈
  • 对于撤销一个操作。如果栈的顶部是A类操作,这显然是最旧的操作,我们直接出栈即可。否则,我们不断弹出栈顶元素,直到栈空或者弹出元素中A类和B类数目相同。之后调整顺序重新入栈,可以保证此时栈顶元素一定是A类的,这个过程我们称为修复。之后正常出栈即可。

下面我们证明上面这个过程中出栈和入栈次数的上限为O(nlog2n),其中n是操作总数。

证明如下:

我们发现上面所有操作的次数的复杂度都是线性的,除了修复过程。下面讨论修复过程。

一种情况是所有元素出栈,这时候我们会将所有B类元素转换为A类元素,因此我们可以估算出这阶段的入栈出栈次数上限为O(n)

第二种情况是出栈的B类元素和A类元素数目相同,这时候出栈和入栈次数实际上是涉及的B类元素的数目的两倍,因此我们只需要讨论在修复过程实际出栈的B类元素总数即可。记ci为第i次操作中修复过程且是第二种情况下,出栈的B类元素数目。因此第二种下入栈和出栈的次数为O(ni=1ci)

这里我们需要发现一个重要的性质,对于任意k,如果存在i<jci,cjk,那么一定有ji>k。假设jik,由于操作j一定是撤销,因此中间最多有k1个操作,因此操作j中一定会出栈操作i出栈的B类元素中的一部分。设其中有x个出队操作,以及k1x个入队操作,那么由于kx>k1x,因此不可能会出栈操作i出栈的B类元素,与假设相悖。

我们重写公式如下:

ni=1ci=ni=1nj=1[cji]ni=1ninlnn

因此我们可以得出算法的时间复杂性上限为O(nlog2n)次的出栈(撤销)和入栈操作(执行)。

并且如果请求为入栈出栈交替,那么就可以让出栈和入栈次数达到我们计算得到的上限。

回到我们一开始提到的问题,我们可以用不带路径压缩的并查集来实现栈式撤销,并且用上面的算法来保证操作和撤销次数为O(mlog2m),总的时间复杂度为O(mlog2nlog2m),但是常数很小。

提供几道题目:

题目1:给定n条顶点,m条无向边,之后提供q个请求,第i个请求为li,ri,要求回答仅考虑编号在[li,ri]之间的边,判断所有顶点是否连通。其中1n,m105,1q106

我们记R(i)表示最小的整数j,满足仅考虑编号在[i,j]之间的边,图连通。可以发现R(i)是个递增函数,因此我们可以通过撤销队列实现滑动窗口的效果,来预处理R。之后每个请求直接查表回答即可。时间复杂度为O(mlog2nlog2m+q)

题目1:给定n条顶点,m条无向边,之后提供q个请求,第i个请求为li,ri,要求回答仅考虑编号在[li,ri]之间的边,判断所有顶点是否连通。其中1n,m105,1q106

我们记R(i)表示最小的整数j,满足仅考虑编号在[i,j]之间的边,图连通。可以发现R(i)是个递增函数,因此我们可以通过撤销队列实现滑动窗口的效果,来预处理R。之后每个请求直接查表回答即可。时间复杂度为O(mlog2nlog2m+q)

题目3:提供n个商品,第i件商品的价格为wi,价值为vi。我们总共有m单位金钱,希望能买到总价值最大的货物。换言之,我们希望选择一些商品,这些商品的总价格不超过m的前提下保证总价值最大。同时,如果我们同时购买k件物品,那么这k件物品中的一件我们可以免费以五折优化买走(如果价格不能整除2,则上取整),且上述优惠最多只能发生一次。问最大能取走的货物的总价值。其中1n,m30001wi,vi1091kn

这道题目,我们只需要理解了背包问题中加入新的商品这一操作也满足交换性,且很容易通过快照的方式进行撤销。因此我们只需要维护一个撤销队列,其中存放商品的一段前缀和后缀的背包信息即可。时间复杂度为O(nmlog2n)

优先队列撤销

了解了队列撤销后,会发现在实际情况下队列撤销并不是很使用,因为大部分情况,撤销操作都是乱序的。

现在考虑如果我们能提前知道撤销操作的发生的顺序,那么就可以利用优先队列撤销来解决。

这里是原文地址[Tutorial] Supporting Priority-Queue-like Undoing on DS

优先队列撤销是指我们要维护一个优先队列,所有的操作都带有优先级。优先队列支持两类行为:

  1. 应用某个操作
  2. 撤销优先级最高的操作

具体的实现非常简单。我们维护一个栈,当一个操作入栈时,我们就应用这个操作,当一个操作出栈时,我们就将这个操作撤销。

之后对于行为1,我们直接将操作加入到栈顶即可。而对于操作2,这里要特殊处理。最简单的方式是我们把所有的栈中元素弹出,重新排序后按照优先级从低到高插入到栈中。但是这样会带来非常糟糕的时间复杂度。这里我们采用另外一种巧妙的方法,我们找到最小的正整数k,使得栈顶的k个元素中,同时包含了栈中优先级最高的C(k)=k/2个操作,这样的k是一定存在的。之后我们将前k个元素弹出,并且将优先级较低的元素先按照任意顺序插回到栈中,而优先级最高的C(k)个元素则之后按照优先级从低到高依次插入到栈中。

为了方便实现,同时为了减少时间复杂度,我们可以为每个操作维护它们在栈中距离栈底的偏移量,同时维护一个包含所有栈中元素的有序集合(二叉平衡树)。之后对于撤销我们只需要枚举有序集合的前面C(k)个元素即可,这样我们顺带还能避免显式对这C(k)个元素进行排序从而恶化时间复杂度。

可以发现插入操作会带来O(logn)的时间复杂度,同时一次操作的执行。而撤销操作会带来O(logN+k)的时间复杂度以及k次操作的撤销执行。

现在我们来计算撤销操作的摊还时间复杂度。

命题:假设第i次操作和第j次操作都是撤销操作,二者出栈的元素数目分别为cicj,且i<j,那么2(ji)min(ci,cj)

k=min(ci,cj),考虑ji<k2。在完成第i次操作后,优先级最高的C(k)1个元素放置在栈顶,记这些元素构成的集合为U。记ij之间插入操作数为m,很多显然U中至少有C(k)1(ji1m)=C(k)+m(ji)>m个元素。因此在第j次操作时,至多弹出顶部的2m2(ji1)<k2个元素,这与cjk相悖,因此假设不成立。

利用这个命题可以直接得到时间复杂度,这里如果第t次操作不是撤销操作,则定义ct=0

ni=1ci=nt=1ni=1[cit]nt=1nt=O(nlnn)

因此可以证明每次撤销操作所应用和取消的操作数的摊还数量为O(logn)。因此优先队列撤销的时间复杂度为

  1. 插入操作:1次操作应用,并附加O(logn)时间复杂度。
  2. 删除操作:O(logn)次操作应用和取消,并附加O(logn)时间复杂度。

非顺序撤销

现在考虑一组操作,如果它既不是栈式,也不是队列撤销的话该怎么办。

实际上类似于队列操作,如果操作满足交换性(操作的执行顺序不影响结果,只有是否被执行对结果有影响),那么我们可以通过离线请求后通过线段树来加速。

对于一个操作,其生效时间为其发生时间,其失效时间为其被撤销的时间。现在我们维护一个线段树,线段树结点[L,R]中存储所有生存时间覆盖这个区间的操作。这样我们发现任意一次操作最多会同时出现在O(log2n)个线段树结点中。

接下来对于结果的查询,我们可以在线段树上DFS,当我们遍历到顶点v的时候,将v中的所有操作全部应用,在离开顶点v的之后,将v中的所有操作全部回退。可以发现上述的所有操作都是栈式的,而所有操作栈式都是可以支持回退的。当我们遍历到线段树的叶子顶点[i,i]的时候,现在的状态等价于执行了所有发生在i之前,且未被撤销的操作的状态。我们直接统计结果即可。

上面的过程中每个操作都最多被执行和撤销O(log2n)次。可以发现这个结果和队列式撤销是一致的,而且大部分时间队列式撤销也可以通过这个技术来解决,当然不要忘记队列式撤销的优势是它支持在线操作。

题目1:给定n个物品,第i件物品的价值为vi,重量为wi。之后考虑q个请求。每个请求要么新增一个物品,要么删除一个物品,要么查询容量为k的背包最大能装走的物品总价值。其中1n,q1041k103

提供一道题目

如果没有删除,实际上这个问题只是一个简单的背包DP。这里的难点实际上删除带来的。并且由于题目中没有说明用的是队列还是栈式删除,因此我们也不能直接应用这些撤销技术。

我们可以直接用上面提到的线段树的技术解决这个问题。时间复杂度为O(k(n+q)log2n)

题目2:给定一副n个顶点没有边的无向图。之后处理q个请求,第i个请求要么删除一条边,要么加入一条边,要么查询两个顶点之间是否存在一条路径。其中1n,m105

由于不带路径压缩的DSU也是支持栈式撤销的,因此可以直接用上面提到的技术,时间复杂度为O(qlog2nlog2q)

参考资料