杂项

可撤销并查集

众所周知,传统的并查集只能支持将两个连通块合并,但是在某些情形下我们需要撤销并查集上的一些操作,也就是进行删边操作。这种时候可能就会有大神要用可持久化并查集了,但是实际上单纯的进行删边操作并不需要如此大费周章,只需要使用可撤销并查集即可。

可撤销并查集,可以支持加边操作,以及根据操作从后往前进行删边。它的用途是比可持久化并查集小的,但是要简单的多。

我们考虑在每一次进行合并操作的时候记录下当前被合并(也就是改变了父亲的)的节点,把它放到一个栈里;当要进行撤销操作的时候,就弹出栈顶节点,将它的父亲改成自己,并删除原父亲上的一些信息(例如大小等)。

代码如下:

int fa[Maxn], siz[Maxn];
int s[Maxn], top;

void init() {
	top = 0;
	for(int i = 1; i <= n; i++) {
		fa[i] = i;
		siz[i] = 1;
	} 
}

int find(int x) {
	return fa[x] == x ? x : find(fa[x]);
}

void merge(int x, int y) {
	int fx = find(x), fy = find(y);
	if(fx == fy) {
		return ;
	}
	if(siz[fx] < siz[fy]) swap(fx, fy);
	fa[fy] = fx;
	siz[fx] += siz[fy];
	s[++top] = fy; //记录节点
}

void back(int x) {
	while(top > x) {
		int p = s[top--];
		siz[fa[p]] -= siz[p];//删除信息
		fa[p] = p;//改父亲
	}
}

珂朵莉树

珂朵莉树实际上是一种相当暴力的数据结构,它基于 STL 中的 set 实现。在数据随机的情况下,它进行区间覆盖和其他操作的均摊复杂度是 \(O(n\log n)\) 的。

考虑到区间覆盖后整个区间相当于被分成了几段颜色相同的段,那么我们就在 set 中维护这些信息。珂朵莉树上的每一个节点定义为 \((l,r,v)\),表示区间 \([l,r]\) 的颜色全为 \(v\)

考虑区间覆盖怎样做,实际上可以分成下列三种操作:

  • \(l,r\) 两个端点从以前的整区间内分裂出来。
  • \([l,r]\) 区间内所有块删去。
  • 加入块 \((l,r,v)\)

对于第一个操作,我们利用函数 Split(x) 实现,表示将包含 \(x\) 的块 \((l,r,v)\) 分裂成 \((l,x-1,v)\)\((x,r,v)\)。这个利用二分查找就可以简单实现,代码如下:

auto split(int x) {
    auto it = s.lower_bound({x, 0, 0});//二分查找
    if(it -> l == x) {
        return it;
    }
    it--;
    int l = it -> l, r = it -> r, v = it -> v;
    s.erase(it);
    s.insert({l, x - 1, v});
    return s.insert({x, r, v}).first;//返回地址
}

注意 Split 完要返回 \((x,r,v)\) 这个块的地址,方便接下来的操作。然后两个操作我们使用 Assign(l, r) 实现,只需要先 Split,然后删除两个端点间的所有块,最后插入即可。代码如下:

void assign(int l, int r, int v) {
    auto itr = split(r + 1), itl = split(l);//分裂
    //注意分裂顺序一定不能改,否则可能会 RE
    s.erase(itl, itr);//直接利用地址删除即可
    s.insert({l, r, v});//插入
}

上面就是珂朵莉树的基本函数,对于其他的区间操作,与 Assign 操作是类似的,先分裂区间再处理即可。

根号分治

实际上根号分治完全就是一个很牛的 trick,我们用下面一道例题引入:

给定一个序列 \(a\),需要支持两种操作:

  • 1 x y:将 \(a_x\) 加上 \(y\)
  • 2 x y:询问所有下标 \(i\) 满足 \(i\equiv y\pmod x\)\(a_i\) 之和。

乍一看十分难做,不过我们容易想到下面两种暴力做法:

  • 暴力模拟,操作一直接操作,操作二直接暴力跳所有下标并求和。这样复杂度分别是 \(O(1)\)\(O(n)\) 的。
  • 预处理,记录一个数组 \(b_{i,j}\),表示所有下标模 \(x\) 等于 \(y\) 的值的和。操作一直接操作,接下来暴力维护 \(b\) 数组,操作二直接查询。复杂度分别是 \(O(n)\)\(O(1)\) 的。

不难发现上面两个做法都有些极端,一个 \(O(1)\) 一个 \(O(n)\),如果我们能够将两种暴力均摊一下,是否可以优化复杂度呢?

考虑去设定一个阈值 \(b\)。当操作的数 \(\le b\) 的时候,使用暴力 \(2\) 的方法维护 \(b\) 数组,此时复杂度应该是 \(O(b)\) 的;而当操作的数 \(>b\) 的时候,使用暴力 \(1\) 的方法跳数组,这样最多跳 \(\lceil\frac nb\rceil\) 次,复杂度就是 \(O(\frac nb)\) 的。

那么两者综合起来的复杂度就是 \(O(q(b+\frac nb))\) 的,由基本不等式得,当 \(b\)\(\sqrt n\) 时有最优复杂度,为 \(O(q\sqrt n)\)。由于其阈值取到了 \(\sqrt n\),相当于在 \(\sqrt n\) 处分开处理,因此得名根号分治。

操作分块

分块思想我们并不陌生,在序列上分块的本质就是控制暴力统计的数量,剩余部分整体处理。而实际上,我们可以将这种思想运用到操作序列中;当答案具有可合并性,修改询问不复杂的时候可以考虑操作分块。

操作分块的核心其实就是在操作序列上分块,而它的核心在于:我们每一次对一整个块进行操作的时候,已经处理完了该块之前的询问的答案和操作的贡献。而对于块中的贡献,我们暴力枚举每一个询问,接下来再暴力枚举每一个操作,计算这些操作另外的贡献。最后再将这些贡献传递给下一个块。

不难发现,操作中我们需要暴力去看块中的贡献,这一部分复杂度是 \(O(B^2)\) 的。而当我们分块之后这个复杂度就可以降到 \(O(q)\) 了。而如果前面的块产生的贡献都可以快速计算的话,总复杂度就可以达到 \(O(q\sqrt q)\)。此时再在上面适当加一些数据结构就没有那么紧张了。

连续段 dp

在一类序列计数的问题中,我们状态的转移可能会与相邻的已插入元素的值紧密相关,只有知道其值才能求解。而如果此时在只考虑往序列两端插入的情况下,问题将变得容易解决的时候,就可以利用连续段 dp。

连续段 dp 的操作利用了在序列两端进行操作的优秀性质,其插入操作也只会在序列两端进行。我们可以在整个序列上去考虑插入操作,不难发现,对于一个局部状态,其一定呈现为一段一段的样子。那么此时再插入一个元素就只会存在三种可能:

  • 新建了一个连续段。
  • 贴在另一个连续段上。
  • 将两个连续段连接起来。

于是设 \(dp(i,j)\) 表示当前插入了 \(i\) 个元素,且连续段数量为 \(j\) 的方案数。那么根据上面所说,转移就只有三种情况:

  • 新建连续段:\(f(i,j)\times (j+1)\to f(i+1,j+1)\)\(j+1\) 表示在 \(j\) 个连续段之间插入的方案数。
  • 贴在连续段上:\(f(i,j)\times 2j\to f(i+1,j)\)\(2j\) 表示总共 \(j\) 个连续段,每个连续段有两个端点。
  • 合并两个连续段:\(f(i,j)\times(j-1)\to f(i+1,j-1)\)\(j-1\) 表示在 \(j\) 个连续段之间合并的方案数。

接着 \(O(n^2)\) 转移即可。剩下的状态就因题而异了。

循环矩阵乘法

对于一个循环矩阵:

\[\begin{bmatrix} tot_0 & tot_1 & tot_2 & \cdots & tot_{p-2}\\ tot_{p-2} & tot_0 & tot_1 & \cdots & tot_{p-3}\\ tot_{p-2} & tot_{p-3} & tot_0 & \cdots & tot_{p-4}\\ \vdots & & & & \vdots \\ tot_1 & tot_2 & tot_3 & \cdots & tot_0 \end{bmatrix} \]

如果我们要进行矩阵乘法的话,理论复杂度是 \(O(n^3)\) 的。不过观察矩阵转移的式子会发现:

\[A'_{i,j}=\sum\limits_{k=0}^{p-2}A_{i,k}A_{k,j} \]

由于循环矩阵的特性,我们可以只关注第一行。因此它实际上可以转化成:

\[A'_{0,j}=\sum\limits_{k=0}^{p-2}A_{0,k}A_{k,j} \]

进一步的:

\[A'_{0,j}=\sum\limits_{k=0}^{p-2}A_{0,k}A_{0,(j-k)\bmod (p-1)} \]

此时我们就可以转化成一维数组来看了:

\[A'_i=\sum\limits_{j=0}^{p-2}A_kA_{(i-j)\bmod (p-1)} \]

此时我们就可以 \(O(n^2)\) 单次求解循环矩阵的乘法了。

同余最短路

有这样一类问题,给定了 \(n\) 个整数,可以重复选,让你求一些关于选出的数的总和的信息。例如总和在 \([1,V]\) 内的取值个数或者最小的不能被总和表示的数等等。这一类问题我们通常使用同余最短路求解。

我们以求总和在 \([1,V]\) 内的取值个数为例来说明。我们对 \(n\) 个数从小到大排序,形成 \(x_1,x_2,x_3\cdots,x_n\),然后我们不考虑 \(x_1\),令 \(d_i\) 表示用 \(x_2,x_3,\cdots,x_n\) 能拼凑出来的数中,\(\bmod x_1=i\) 的最小数字。

那么此时对于 \(\forall i\in [0,x_1)\),我们可以进行的操作就是给它加上任意一个 \(x_j\),此时取模 \(x_1\) 后的值就变为 \((i+x_j)\bmod x_1\),也就是进行了一次 \(i\to (i+x_j)\bmod x_1\) 操作。

我们不妨将这个操作看成连边,我们对于 \(\forall i\in [0,x_1),j\in[2,n]\),连边 \((i,(i+x_j)\bmod x_1,x_j)\),然后跑最短路。这样求出的 \(d_i\) 就是我们想要的。而初始状态就是已知的能被拼出来的数的值,大部分情况下都是 \(d_0=0\)

最后我们用 \(d_i\) 求出相应的答案即可。

斯坦纳树

考虑这样一个问题:给出一张带正权无向图和 \(k\) 个关键点,我们需要找出这个无向图的一张子图,使得该子图联通且包含所有关键点,我们需要使这个子图的边权和最小。

显然可以发现,我们求出的联通子图一定是一棵树,因为如果有环删掉一条边肯定更优。而我们求出来的这个树就叫做斯坦纳树。那我们考虑 dp,既然是一棵树,我们就设 \(dp(i,S)\) 表示以 \(i\) 为根的子树内包含关键点集合为 \(S\) 所需要的最小代价。

我们考虑讨论 \(i\) 的度数,其实只有两种情况:

  • \(i\) 的度数为 \(1\),此时我们只需要找到 \(i\) 在树上的唯一一个儿子节点 \(j\),转移方程为:

    \[dp(i,S)\leftarrow dp(j,S)+w(j,i) \]

    其中 \(w(j,i)\)\(j\)\(i\) 的最短路,我们可以直接用最短路算法来求解这一部分。总复杂度是 \(O(nm2^k)\) 的。

  • \(i\) 的度数大于 \(1\),此时我们直接合并 \(i\) 的两个部分即可,转移方程为:

    \[dp(i,S)\leftarrow dp(i,T)+dp(i,S-T) \]

    直接枚举子集并转移即可。总复杂度 \(O(n3^k)\)

所以求解斯坦纳树的复杂度是 \(O(n3^k+nm2^k)\)。不过实际上经过一些简单的常数优化后可以跑过 \(n=200,k=14\),如下:

  • 枚举的时候先枚举 \(S\),再枚举 \(i\),同时交换 dp 数组两维。这样我们可以加快跑最短路时数组寻址速度。
  • 显然 \(T\)\(S-T\) 是对称的,当 \(T<S-T\) 的时候直接退出即可,可以节约一半常数。
posted @ 2024-08-24 17:51  UKE_Automation  阅读(77)  评论(0)    收藏  举报