基环树
基环树简单介绍
#1.0 啥是基环树?
首先,严格地讲,基环树不是树,它是一张有 \(n\) 个节点、\(n\) 条边的图。
#1.1 无向图上的基环树
可以将这种有 \(n\) 个节点、\(n\) 条边的无向联通图看做在一棵树上加了一条边,形成了一张恰好包含一个环的图,如下图:
这便是一个无向图上的基环树。
当然,如果不保证联通,那么有 \(n\) 个节点、\(n\) 条边的无向图也有可能是一个基环树森林。
#1.2 有向图上的基环树
#1.2.1 内向树
每个节点以自己为起点的边只有一条。
#1.2.2 外向树
每个节点以自己为终点的边只有一条。
#2.0 基环树的一般处理思路
下面都以无向图为例子。
#2.1 大体方法
看起来基环树似乎比一棵树变得麻烦了许多。
但不要害怕,我们也是有一般方法的:
- 找到唯一的环;
- 对环之外的部分按照若干棵树处理;
- 考虑与环一起计算。
#2.2 找到环
#2.2.1 思路
找到环可以采用 \(\text{DFS}\) 来实现(我不会 \(\text{BFS}\) QwQ).
以下是本人的口胡做法:
- 从任意一点开始搜索;
- 每次拓展到的点涂为灰色,回溯的点涂为黑色;
- 重复第一、二步,直到通过一条未经过过的边拓展到一个灰色节点;
- 将拓展到的灰色节点涂为红色,函数返回
true
; - 将经过的回溯时经过的节点涂为橙色,函数返回
true
; - 重复第 5. 步,直到回溯到被涂为红色的节点,函数返回
false
,算法结束。
经过以上几步,我们便得到了唯一的环——颜色最显眼的那些!其中红色节点是环的衔接处。
如下图:
注意到,上面的模拟过程中,以节点 \(10\) 为根的子树并没有被遍历,是因为 \(\text{DFS}\) 的实现本身便是一路到底,所以可能并不能遍历整颗基环树。
那么,当我们面对的是一个基环树森林时,便要注意了:如果以节点是否被访问过为标志,那么,标记一整棵基环树有以下几种方法:
- 在环上节点递归前,用另一个 \(\text{DFS}\) 遍历该节点能到达的所有节点,并标记。
- 在对去掉环之后的若干棵子树进行处理时进行另一种标记
tag2[]
,判断是否是一棵新的基环树的依据为tag2[]
是否被标记。 - 建图时使用并查集。
还有几个要注意的点:
- 判断一个点是否可以被拓展的条件是拓展所经过的边是否已被经过,由于无向图建边是两个方向各建一条边,所以标记时需要成对变换,将这两条边同时标记。
#2.2.2 代码实现
//R[] 存储的是环上的节点
inline bool Get_ring(int x,int from){
if (v[x] == 1){
v[x] = 2;v2[x] = 1;
R[++ tot] = x;
return true;
}
v[x] = 1;int opt = 0;
for (int i = head[x];i != -1;i = e[i].nxt){
if (v_e[i]) continue;
v_e[i] = v_e[i ^ 1] = true;
if (Get_ring(e[i].v,x)){
pre_e[e[i].v] = i;
if (v[x] != 2){
R[++ tot] = x;
v2[x] = 1;
return true;
}
else return false;
}
}
return false;
}
#2.3 剩下的操作
剩下的两步多要视题目而定了,不过一般是一个树形DP(子树不考虑环)加一个线性DP+单调队列优化(在环上,断环为链,复制一遍)
基环树的定义为:一个有向或无向图中,只含有一个环的图,形式化的表达为:
关于这种形状关键点主要在于找环,那么我们可以一步一步的去寻找,当这个点走着走着走到了某个环里,我们可以直接遍历整个环,然后打个标记,这样环就找到了
具体的例题:
E - Reachability in Functional Graph
本题就是一个基环树森林,即不同的连通块,每个块内可能存在一个基环树,那么我们直接暴力的找出来每个环,并且算出环的节点个数,那么每个点的贡献就是,从这个点出发到环内某一节点经过的边数+环的大小,然后再直接进行记忆化搜索即可:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10,mod=1e9+7;
signed main()
{
std::ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
int n; cin>>n;
vector<int>a(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
vector<int>vis(n+1),dp(n+1); // vis 数组是用来标记这个点是否被遍历过
// dp 环中每个点的贡献
for(int i=1;i<=n;i++){
if(vis[i]) continue; //如果被遍历到了,直接跳过
int u=i;
while(true){ //一直遍历下去
if(vis[u]){
if(vis[u]==i){ //如果是第一次被标记,也就是某个环的入口,我们就计算一下这个环
int x=a[u],cnt=1;
while(x!=u){ //走回来就停止
cnt++,x=a[x];
}
dp[u]=cnt,x=a[u];
while(x!=u){
dp[x]=cnt,x=a[x]; //每个节点的贡献都是这个环的大小
}
break;
}
}
vis[u]=i,u=a[u]; //向下传点
}
}
function<int(int)>dfs;
dfs=[&](int u)->int{
if(dp[u]) return dp[u];
return dp[u]=dfs(a[u])+1;
};
int res=0;
for(int i=1;i<=n;i++) res+=dfs(i);
cout<<res;
return 0;
}
E - Permute K times
稍微模拟一下会发现, 从任意一个点出发, 可以向它要到达的点连一条单向边, 从而可以转化成图上问题. 由于是 \(n\) 个点 \(n\) 条边, 且从任意一个点开始考虑, 发现在途中可能会走进一个环或者没有环, 很明显的可以转化为基环树, 而且各边不一定相连, 所以是个 基环树内向森林, 则问题转化为, 从该基环树为起点的第 \(k\) 级祖先问题.
可以通过倍增解决 \(k\) 级祖先问题:设 \(g(j, i)\) 表示从点 \(i\) 出发沿着出边走 \(2^j\) 次后到达的点,则有边界 \(g(0, i) = x_i\) 和转移 \(g(j + 1, i) = g(j, g(j, i))\)。要回答询问,只需将 \(k\) 进行二进制拆分,后对每个进制位 \(j\) 进行 \(p \gets g(j, p)\) 的跳跃即可。
时空复杂度为 \(\mathcal O(n \log k)\)。
注:由于这里的 \(k\) 级祖先可以离线询问,存在线性时空的做法。具体而言,找到每个连通块的环后进行 DFS,记录 DFS 栈,然后定位栈上从当前点往前数 \(k\) 位的点,或栈长不足 \(k\) 时定位环上的特定点。由于代码实现比倍增的做法复杂,不展示具体代码。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e6 + 10, mod = 1e9 + 7;
signed main()
{
std::ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
int n, k; cin >> n >> k;
vector<int> x[60];
x[0].resize(n + 1);
for(int i = 1; i <= n; i++) cin >> x[0][i];
for(int j = 1; j <= 59; j++){
x[j].resize(n + 1);
for(int i = 1; i <= n; i++){
x[j][i] = x[j - 1][x[j - 1][i]];
}
}
vector<int> a(n + 1);
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= n; i++){
int p = i;
for(int j = 0; j <= 59; j++){
if(k & (1LL << j)){
p = x[j][p];
}
}
cout << a[p] << " \n"[i == n];
}
return 0;
}