队列图完全连通性
终于有能拿出来的东西了。
最初的问题
题意转化之后是双指针,每次加一条边,查询连通性,删除最早的边。
标准做法是 动态图完全连通性。
但是继续观察,此题提供了两个特殊性质。
- 保证图为森林。
- 每次删边只删最早的。
基于性质 1 自然可以得到 LCT 的做法,也就是大多数题解的做法。
下面提出一种基于性质 2 的算法。
先描述一下问题:
维护 \(n\) 个点和一个队列,支持一下操作:
1 a b
在队列末尾加入一条无向边 \((a, b)\)。
2
删除队列首。
3 a b
查询只考虑队列中的边的情况下,\(a, b\) 两点是否连通。
强制在线。
连通性问题首选并查集。
带删边就用可撤销并查集。
然而可撤销并查集的结构类似于栈(只能删除最后加入的元素)
分块算法
我们对边序列进行分块。
维护队列其实也就相当于是维护双指针(单调)
考虑若目前的两个指针分别为 \(l, r\)。
\(l\) 在第 \(B\) 个块里。
我们先从第 \(B+1\) 个块开始顺次插入边,直到 \(r\)。
然后再从第 \(B\) 个块末尾开始倒序插入边,直到 \(l\)。
这样 \(l\) 就是最后被插入的边了。
考虑左删除,直接删即可。
若 \(l\) 跑到了下一个块,就暴力重构所有边。
考虑右插入,我们先将第 \(B\) 个块的边全部删除,再插入新的边,然后再把第 \(B\) 个块的边插回来。
容易发现每一步 插入/删除 的次数均不超过 \(O(\sqrt n)\)。
复杂度达到 \(O(n^{1.5}\log n)\)。
线段树算法
容易发现上述算法的本质就是调整了插入并查集的边的顺序,使得插入和删除都比较容易。
那么能否使用分治结构实现?
Lemma:在线段树上用 \(O(\log n)\) 个节点表示一个区间时,位于每一层上面的节点至多只有 2 个。
显然,这个涉及到线段树区间操作复杂度的证明。
分类讨论递归情况即可。
我们提出以下结构维护:
若目前的左右指针为 \(l, r\),则在线段树上划分出来 \(O(\log n)\) 个小区间表示 \([l, r]\)。
随后按照深度从小到大的方式依次插入这些小区间。
首先,容易发现在指针扫过去的过程中,线段树上的每个节点至多被加入一次删除一次。
加入/删除 的时候,均需要先把深度更大的节点删除,而后再加回来。
我们知道 \(T(n) = T(\frac{n}{2}) + O(n)\),\(T(n) = O(n)\)。
也就是深度更大的那些节点的 \(size\) 的和与这个节点的大小同阶。
那么我们就可以放心操作了,因为复杂度上等价于只删除了这个节点。
总的 插入/删除 次数 \(O(n \log n)\)。
复杂度 \(O(n \log^2 n)\)。
倍增算法
线段树算法的实现较为困难,且难以拓展。
下面提出一种基于倍增实现的算法,理论上可以实现双端队列。
(即队列首尾同时插入删除)
考虑一种有 \(O(\log n)\) 层的结构,其中第 \(i\) 层能够存储 \(2^i\) 个元素。
插入就直接扔到第 \(0\) 层,若超出这层的容量限制就全部扔到上面一层。
容易发现这个结构类似于二进制分组。
现在我们维护两个这种结构 \(A, B\)。
插入并查集时仍然是先插大的层后插小的层。
新的边只插入 \(B\),删除时只删除 \(A\)。
若 \(A\) 目前为空,则从 \(B\) 中找到元素最多的层(容易发现一定是最早被插入的若干个元素)而后直接转移到 \(B\) 中。
删除无非就是插入的逆过程。
上述结构与线段树类似,但是更容易维护。
复杂度 \(O(n \log^2 n)\)。
再考虑如果改成首尾均插入/删除怎么办。
直接删的话复杂度会假。
我们采用经典倍缩策略,第 \(i\) 层的大小缩减到 \(2^{i - 1}\) 以下才向下合并。
也就是超过 \(2^i\) 向上合并,低于 \(2^{i - 1}\) 向下合并。
这样删除操作也可以均摊分析。
倍增版实现
提交记录。
namespace mset
{
const int sz = 400005;
int fa[sz], dep[sz];
vector<int> sta;
void init(int n)
{
for (int i = 1; i <= n; ++i)
fa[i] = i;
for (int i = 1; i <= n; ++i)
dep[i] = 1;
}
int find(int a)
{
while (fa[a] != a)
a = fa[a];
return a;
}
void merge(int a, int b)
{
a = find(a); b = find(b);
if (dep[a] > dep[b])
std::swap(a, b);
sta.push_back(a);
fa[a] = b; dep[b] += dep[a];
}
void roll(int a)
{
while (sta.size() > a)
{
dep[fa[sta.back()]] -= dep[sta.back()];
fa[sta.back()] = sta.back();
sta.pop_back();
}
}
};
namespace quegraph
{
unsigned l = 0, r = -1;
const int LG = 20;
vector<pair<int, int> > add[LG], del[LG];
int top1[LG], top2[LG];
void back(int a)
{
while (a >= 0)
{
if (del[a].size())
{
top2[a] = mset::sta.size();
for (auto i : del[a])
mset::merge(i.first, i.second);
}
if (add[a].size())
{
top1[a] = mset::sta.size();
for (auto i : add[a])
mset::merge(i.first, i.second);
}
--a;
}
}
void insert(int a, int b)
{
for (int i = 0; ; ++i)
if (add[i].size() == 0)
{
if (i != 0)
{
if (del[i - 1].size())
mset::roll(top2[i - 1]);
else
mset::roll(top1[i - 1]);
}
top1[i] = mset::sta.size();
for (int j = i - 1; j >= 0; --j)
{
for (auto k : add[j])
{
add[i].push_back(k);
mset::merge(k.first, k.second);
}
add[j].clear();
}
add[i].emplace_back(a, b);
mset::merge(a, b);
back(i - 1);
break;
}
}
void delt()
{
for (int i = 0; ; ++i)
if (i == LG)
{
for (--i; ; --i)
if (add[i].size())
{
del[i].swap(add[i]);
top2[i] = top1[i--];
break;
}
continue;
}else if (del[i].size())
{
mset::roll(top2[i]);
int j = 1, k = 0;
while (j < del[i].size())
{
del[k].push_back(del[i][j++]);
if (del[k].size() == (1U << k))
++k;
}
del[i].clear();
back(i);
break;
}
}
bool check(int a, int b)
{
return mset::find(a) == mset::find(b);
}
};
或许把 vector
换成数组能大大减小常数。
但是为了封装方便还是用 vector
实现的。
后记
与 \(Lyin\) 交流的过程中,\(Lyin\) 提出只依靠性质 2 也可以使用 \(LCT\) 维护。
具体的,当出现环的时候就删除这条链上时间最早的边。
复杂度 \(O(n \log n)\) 好像,比这个优。
不过考虑常数影响的话就不一定了。
后来在 UOJ 发现原题。
题目链接。
做法完全一致。