按照思维难度加大和代码难度减小的顺序,我们来看这道题的不同做法。
若你无畏,我亦无畏 - 平衡树
平衡树简直是天然用来维护这种操作的——合并两个区间,提取一个值。我们可以对每个行的前 \(m-1\) 位和最后一列各维护一棵平衡树。平衡树上二分得到要删除的数,将当前区间分成 左边 - 要提走的数 - 右边。然后合并左边和右边,在末尾加上会新来的数。
但是这里存在一些问题,也就是我们不能真的给每一行开大小为 \(m-1\) 的平衡树,这怎么办呢?
-
策略一:动态开点。我们一开始只插入一个大小为 \(m-1\) 的节点代表整个一开始的平衡树,当它的左右儿子存在但是不真正插入。一旦查询到,再把左儿子和右儿子插入进去,再往下遍历。因为我们最多访问 \(q\) 次,所以最多插入 \(O(q\log n)\) 个新点。
-
策略二:断点分裂。我们在平衡树上维护区间,\([l,r]\) 的区间。同时记录它的大小和原始对应的 \(l\)。当我们查询到要的点是 \([l,r]\) 之间的第 \(x\) 个数时,就返回 \(l+x-1\)。然后删除的时候,将 \([l,r]\) 分裂成 \([l,x-1]\) 和 \([x+1,r]\)。我们发现,每个平衡树其实都是在所有被删除的点的间隔下被分成了若干个连续的区间。每次查询最多分裂出两个区间,点数是 \(O(n)\) 级别的(线性空间)。
-
策略三:两个 \(Treap\)。我们在前面的过程中,都是实现了 \(n+1\) 棵同功能平衡树。我们想想两棵不同功能平衡树有什么好处——我们可以一个用来记录正常位置被删掉的点,一个用来记录所有新加入的点!那么,我们可以在所有被删除的点上同时维护紧跟着它的一段区间,在”删除 \(Treap\)”上二分找到答案。如果“删除 \(Treap\)”本身已经没有足够多的数了,就在新加入的点里面,也就是“插入 \(Treap\)”里面二分,得到答案。然后每次在“插入/删除 \(Treap\)”上增加一个点,在“插入 \(Treap\)”中增加一个点。
当然,以上的所有平衡树做法中,大部分都使用于所有的平衡树。不过因为是维护区间的缘故,当以 \(fhqTreap\) 为最佳选择。
转圜余地,权衡之策 - 线段树 (Pure)
我们可以使用 \(n+1\) 个动态开点线段树。在节点上记录当前行当前区间没有被删除的数的总数,在叶子节点记录当前点的数。每次在线段树上二分。最多插入 \(O(q\log n)\) 个新点。
在平衡树中存在的问题,在线段树中依然存在。不过因为动态开点线段树的天然性质,解决的方案要更加方便。
-
策略一:动态权值。对于一个区间,它 \(m-1\) 以内的部分是有先天权值的,以外的部分是没有先天权值的。那么我们在线段树 \(newnode\) 的时候,将新的 \(sum\) 设定成在 \(m-1\) 以内的部分长度。也就是 \(\max\{\min\{r,m-1\}-l+1,0\}\)。这样我们就可以方便的进行二分和加点了。
-
策略二:两种线段树。我们给每行开两种线段树,一种维护前 \(m-1\) 个数,一种维护新加入的所有数。第一种的先天权值是 \(r-l+1\),只需要支持动态开点删除。第二个负责在新点超出范围的时候查找,不仅删除还要插入。两种不同的先天权值区间用两个不同的线段树来概括,主要代码可以大段复制,只在 \(newnode\) 等为数不多的地方进行更改,免去了维护最后一列和其他长度不一样的烦恼。
-
策略三:满赋溢出。我们发现,我们其实只需要维护前 \(m-1\) 个有值的位置,而插入其实是从尾部逐步插入。已经被加入的部分本来就应该是 \(1\),后面的部分在二分的时候根本不会被关注到,我们不如直接给所有的区间的先天权值都设为 \(1\)(因为溢出部分的权值对前 \(m-1\) 的查询没有影响)。我们也不需要支持插入了,只需要更改叶子节点的 \(value\) 即可。因为每次修改修改一条链或一个点,还可以使用特殊的方法建树得到更优秀的实现。
\(+\) 其他技巧
-
策略一:提前处理。我们假设删除点的时候不是删除合并两边,而是保留原来的点。每次处理一行。也就是提前 \(O(n)\) 建出一棵不动态开点的线段树,对于每一行的所有询问,找到它对应原来的位置的哪个点(也就是如果不删除点,只是把点改成 \(0\),它对应 \(m-1+q\) 内第几个点),删掉这个点。整行查询完之后,将所有被删除的位置加回去。预处理对应位置的复杂度是 \(O(q\log n)\) 的。然后模拟部分就不再用到线段树。因为我们已经提前处理出了坐标,所以可以直接利用新的映射关系在 \(Append_x\) 序列中 \(O(1)\) 按照下标查询。至于删除就完全不需要处理了,因为我们维护的就是静态的序列。每次在 \(Append_n\) 里面加一个点即可。
-
策略二:预后处理。和上面的做法反其道而行之。我们发现,每个询问的答案,要么依赖于前面删除了多少个数,要么依赖于后面加进来什么数。而前面删除了多少个数这种很好处理,对于后面加进来的数,我们可以不去管它加进来什么,而是找到那次修改它它的询问编号。我们对每个询问,处理出它的父亲——也就是它的答案依赖于哪个位置原先的答案。这一过程可以使用线段树,也就是在线段树上二分没有被删掉的位置。如果当前位置的值依赖于 \(append\) 进来的数,就把它的父亲挂上去。否则就是当前行列的答案。最后像并查集压缩路径一样找到每个点的答案。压缩直到当前询问不依赖于别的询问为止。
-
小技巧:权值处理。我们发现值分两种,一种是原先表中的规律的值,一种是插入进来的新值。原先表中规律的值很好处理,但是新值有点麻烦,需要在线段树的每个点记录。这样就要给每个节点加一个新的信息,增加了规律值的浪费。我们可以用 \(Hash\),对于规律的值,直接按照公式给出答案。否则存进 \(Hash\) 表。每次直接往 \(Hash\) 表里查询,就可以优化一点空间的常数(动态开点,空间很重要)