最小生成树相关
次小生成树、生成树边替代
对于一条非树边 \((u,v)\),它替代树 \(u,v\) 链上的最大值,对答案的影响最小。用倍增或树链剖分维护。
对于一条树边,如果非树边 \((u,v)\) 满足这条边在链 \(u,v\) 上,它被满足这个条件的权值最小的边替代对答案的影响最小。把非树边按权值排序,用并查集进行路径压缩。
\(k\) 路归并(二路归并)
有 \(k\)(\(2\)) 个序列,每个序列选一个数,求所有选数方案前 \(k\) 小和。
多次进行二路归并来实现 \(k\) 路归并。
二路归并可以用堆实现,时间复杂度 \(O(k\log k)\);也可以直接归并,这样整个 \(k\) 路归并的时间复杂度为 \(O(nk)\)。
于是,你就可以求仙人掌 \(k\) 小生成树。(这玩意真的有用吗)
Kruskal 与状压 DP
DP 记录当前并查集的状态,状态数量是集合划分的方案数,也就是 Bell 数,可以哈希存状态,手写哈希并用栈记录状态常数小,但代码难写。其实还好。
Kruskal 重构树
1. 定义与构造
对于一张无向图,新建 \(n\) 个树,原图每个点在一个树中,权值是 \(0\)。按边权从小到大枚举边,如果这条边两个节点不在一棵树,合并两个节点所在的树。新建一个点,点权为加入边的边权,同时将两棵树的根节点分别设为新建点的左儿子和右儿子,将新建点设为根。
实现与 Kruskal 最小生成树算法类似,用并查集实现。
代码:
struct edge{
int u, v, w;
bool operator <(const edge &p) const
{
return w < p.w;
}
}e[N];
int gfa(int i)
{
return i == fa[i] ? i : (fa[i] = gfa(fa[i]));
}
void Kruskal()
{
for(int i = 1; i < n * 2; i ++ ) fa[i] = i;
k = n;
sort(e + 1, e + m + 1);
for(int i = 1; i <= m; i ++ )
{
int u = gfa(e[i].u), v = gfa(e[i].v);
if(u != v)
{
w[ ++ k] = e[i].w;
ls[k] = u, rs[k] = v;
f[u] = f[v] = fa[u] = fa[v] = k;
}
}
}
2. 性质与应用
kruskal 重构树有以下性质:
- 如果原图连通,重构树是一棵 \(2n-1\) 个节点的二叉树,根节点是最后插入的节点,每个编号小于等于 \(n\) 的节点没有儿子,编号大于 \(n\) 的节点有两个儿子。
- 满足大根堆的性质,即每个节点权值大于等于儿子节点权值。
- 原图中两个点之间的所有路径上最大边权的最小值等于 Kruskal 重构树上两点之间的 LCA 的权值。
由这些性质,kruskal 重构树主要有两种应用(可能不全,碰到再补充):
- 在线求一个无向图两点间简单路径的最大边权最小值。
- 在线修改和查询所有可以由一个点 \(u\) 不通过权值大于 \(w\) 的点的信息:用树上倍增找到 \(u\) 父节点中满足权值小于等于 \(w\) 的深度最浅的节点 \(v\),则满足条件的点是 \(v\) 的子树中所有叶子节点。
实际上对于第二个应用,如果不强制在线且没有修改操作,可以用并查集实现:边按权排序,询问按 \(w\) 排序,处理每个询问前用并查集连边权小于 \(w\) 的边,用并查集处理询问,时间复杂度 \(O(nlogn)\)
3. 题目
(1) P3684 [CERC2016]机棚障碍 Hangar Hurdles
首先用二分法求出每个点为中心的正方形最大边长,然后所有相邻的点连一条边权值为两个点最大边长的最小值,问题转化为在无向图上求两个点路径最小边权最大值,构建 kruskal 重构树求 LCA 即可。时间复杂度 \(O(n^2logn)\)
代码
// 求最大正方形边长
int get(int ax, int ay, int bx, int by)
{
return s[bx][by] - s[ax - 1][by] - s[bx][ay - 1] + s[ax - 1][ay - 1];
}
for(int i = 1; i <= n; i ++ )
for(int j = 1; j <= n; j ++ )
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + (a[i][j] == '#');
for(int i = 1; i <= n; i ++ )
for(int j = 1; j <= n; j ++ )
{
int l = 0, r = min(min(i, n - i + 1), min(j, n - j + 1)), mid;
while(l < r)
{
mid = l + r + 1 >> 1;
if(get(i - mid + 1, j - mid + 1, i + mid - 1, j + mid - 1)) r = mid - 1;
else l = mid;
}
t[i][j] = l;
}
// Kruskal 重构树
void kruscal()
{
k = n * n;
for(int i = 1; i < k * 2; i ++ ) fa[i] = i;
sort(e + 1, e + m + 1);
for(int i = 1; i <= m; i ++ )
{
int u = gfa(e[i].u), v = gfa(e[i].v);
if(u != v)
{
w[ ++ k] = e[i].w;
ls[k] = u, rs[k] = v;
f[u][0] = f[v][0] = fa[u] = fa[v] = k;
}
}
}
void dfs(int u)
{
dep[u] = dep[f[u][0]] + 1;
for(int i = 1; i < K; i ++ ) f[u][i] = f[f[u][i - 1]][i - 1];
if(ls[u]) dfs(ls[u]), dfs(rs[u]);
}
int lca(int u, int v)
{
if(dep[u] < dep[v]) swap(u, v);
for(int i = K - 1; i >= 0; i -- )
if(dep[f[u][i]] >= dep[v])
u = f[u][i];
if(u == v) return u;
for(int i = K - 1; i >= 0; i -- )
if(f[u][i] != f[v][i])
u = f[u][i], v = f[v][i];
return f[u][0];
}
// 调用
w[lca(u, v)];
(2) 万松园
题意描述:给定一棵 \(n\) 个节点的树,多次询问,每次询问给定 \(u,w\),求 \(u\) 不经过边权小于 \(w\) 的边能到达点的数量。
第二个运用。记录重构树上每个 \(2^k\) 级祖先,查询时从大到小枚举 \(k\),如果 \(u\) 的 \(2^k\) 级祖先的权值大于等于 \(w\),\(u\) 设为 \(u\) 的 \(2^k\) 级祖先。
因为不强制在线,用并查集可以做而且思路比较简单。
可以发现,无论是并查集的离线做法还是 Kruskal 重构树的在线做法,重构树的在线做法,原图是树的条件没有多大作用,只是合并的时候可以不用判断是否在一个集合,也就是说,一般的无向图也可以用这种做法。所以,这个条件是用来钓鱼的
代码
int gfa(int i)
{
return i == fa[i] ? i : (fa[i] = gfa(fa[i]));
}
void kruskal()
{
sort(e + 1, e + m + 1);
for(int i = 1; i < n * 2; i ++ ) fa[i] = i;
k = n;
for(int i = 1; i <= m; i ++ )
{
int u = gfa(e[i].u), v = gfa(e[i].v);
w[ ++ k] = e[i].w;
ls[k] = u, rs[k] = v;
f[u][0] = f[v][0] = fa[u] = fa[v] = k;
}
w[0] = -1;
}
void dfs(int u)
{
for(int i = 1; i < K; i ++ ) f[u][i] = f[f[u][i - 1]][i - 1];
if(ls[u])
{
dfs(ls[u]), dfs(rs[u]);
cnt[u] = cnt[ls[u]] + cnt[rs[u]];
}
else cnt[u] = 1;
}
int query(int u, int k)
{
for(int i = K - 1; i >= 0; i -- )
if(w[f[u][i]] >= k)
u = f[u][i];
return cnt[u] - 1;
}
(3) P4768 [NOI2018] 归程
首先可以想到求所有点到终点的最短路径,即节点 1 到所有点的最短路径,用Dijkstra 算法求(SPFA 就在这里死的)。
问题转换为给定 \(u,w\),求 \(u\) 不经过边权小于等于 \(w\) 的边能到达点的到 1 节点的距离的最小值,构造 kruskal 重构树用书上倍增解。
代码
void dijkstra()
{
memset(dis, 0x3f, sizeof dis);
dis[1] = 0;
memset(mark, 0, sizeof mark);
priority_queue<PII, vector<PII>, greater<PII> > pq;
pq.push({0, 1});
while(pq.size())
{
int u = pq.top().y;
pq.pop();
if(mark[u]) continue;
mark[u] = true;
for(int i = h[u]; i; i = ne[i])
{
int v = en[i];
if(dis[v] > dis[u] + le[i])
{
dis[v] = dis[u] + le[i];
pq.push({dis[v], v});
}
}
}
}
int gfa(int i)
{
return i == fa[i] ? i : (fa[i] = gfa(fa[i]));
}
void kruskal()
{
for(int i = 1; i < n * 2; i ++ ) fa[i] = i;
k = n;
sort(e + 1, e + m + 1);
for(int i = 1; i <= m; i ++ )
{
int u = gfa(e[i].u), v = gfa(e[i].v);
if(u != v)
{
w[ ++ k] = e[i].w;
ls[k] = u, rs[k] = v;
f[u][0] = f[v][0] = fa[u] = fa[v] = k;
}
}
w[0] = -INF;
}
void dfs(int u)
{
for(int i = 1; i < K; i ++ ) f[u][i] = f[f[u][i - 1]][i - 1];
mindis[u] = dis[u];
if(ls[u]) dfs(ls[u]), mindis[u] = min(mindis[u], mindis[ls[u]]);
if(rs[u]) dfs(rs[u]), mindis[u] = min(mindis[u], mindis[rs[u]]);
}
int query(int u, int t)
{
for(int i = K - 1; i >= 0; i -- )
if(w[f[u][i]] > t)
u = f[u][i];
return mindis[u];
}
(4) Donation
在 kruskal 重构树上 dp,题解。
总结
最大边最小=最小 \(x\) 保留大于等于 \(x\) 的边连通=最小生成树。
两两之间最小瓶颈最大值=重构树按 DFS 序排序后相邻两点 LCA 权值最大值。
计算一条边的贡献。
找权值满足要求深度最小的点,除了倍增以外,还可以树剖+二分。
最短路相关
补图处理
用一个链表记录还没有遍历的节点,把每个节点不能到达的节点排序,用双指针求当前节点能到达哪些未被遍历的节点并遍历。
最短路的虚点优化建图、拆点、最短路上的边
二进制分组
用于计算两两之间的最大贡献、不能自己贡献自己。把元素按二进制第 \(i\) 位分为两组互相计算贡献,则仅需计算 \(\log n\) 次。
bellmanford 与可达性判断
可以用来求解无法直接建图的复杂情况。
欧拉路径、欧拉路径拆分
用邻接表遍历,每遍历一条边就删掉它。
拆分见守卫王国。
dijstra 与成环转移 DP
用 dijstra 的方式来更新状态,需要满足一个状态不能被它更新其它状态之前值比它小的状态更新,即不能有负权边。
最短路径树
连通性相关
强连通
模板
void tarjan(int u)
{
dfn[u] = low[u] = ++ dfst;
sta[ ++ tt] = u;
insta[u] = true;
for(int i = la[u]; i; i = ne[i])
{
int v = en[i];
if(!dfn[v])
{
tarjan(v);
low[u] = min(low[u], low[v]);
}
else if(insta[v]) low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u])
{
scc ++ ;
int v;
do{
v = sta[tt -- ];
insta[v] = false;
bel[v] = scc;
}while(v != u);
}
}
缩点
把一个强连通分量缩成一个点,因为这个强连通分量内的点可以经过任意这个强连通分量的点到达任意强连通分量的点,这样就可以形成一个有向无环图,方便处理。
- 直接利用性质:入度、出度为 \(0\) 的点。
- DP:最长路(原图中可以多次经过同一个、点经过的点只计算一次贡献的最长路)、可达性(可以通过 bitset 优化后时间复杂度为 \(O\left(\frac{nm}{32}\right)\),略低于 floyd 算法)。由于 tarjan 的过程已经进行了拓扑排序,且强连通分量的编号靠后的拓扑序靠前,所以不需要再拓扑排序。注意:如果要计算方案,要使缩点后的图没有重边(根据具体情况确定)。
边双连通
模板
桥:
idx = 1;
void tarjan(int u, int fr)
{
dfn[u] = low[u] = ++ dfst;
for(int i = la[u]; i; i = ne[i])
{
int v = en[i];
if(!dfn[v])
{
tarjan(v, i);
low[u] = min(low[u], low[v]);
if(low[v] > dfn[u]) bridge[i] = bridge[i ^ 1] = 1;
}
else if(i != (fr ^ 1)) low[u] = min(low[u], dfn[v]);
}
}
边双连通分量:
void tarjan(int u, int fr)
{
dfn[u] = low[u] = ++ dfst;
sta[ ++ tt] = u;
for(int i = la[u]; i; i = ne[i])
{
int v = en[i];
if(!dfn[v])
{
tarjan(v, i);
low[u] = min(low[u], low[v]);
}
else if(i != (fr ^ 1)) low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u])
{
int v;
scc ++ ;
do{
v = sta[tt -- ];
bel[v] = scc;
w1[scc] += st1[v], w2[scc] += st2[v];
}while(v != u);
}
}
缩点
缩点之后形成一棵树。主要用于求解段了一条边连通性,也可以用于求“对每条边确定一个方向”的路径问题。
点双连通
模板
割点:
void tarjan(int u)
{
dfn[u] = low[u] = ++ dfst;
sta[ ++ tt] = u;
for(int i = la[u]; i; i = ne[i])
{
int v = en[i];
if(!dfn[v])
{
tarjan(v);
low[u] = min(low[u], low[v]);
if(low[v] >= dfn[u]) cut[u] ++ ;
}
else low[u] = min(low[u], dfn[v]);
}
if(u != rt) cut[u] ++ ;
}
// cut[u] > 1 则 u 是割点
点双连通分量:
void tarjan(int u)
{
dfn[u] = low[u] = ++ dfst;
sta[ ++ tt] = u;
for(int i = la[u]; i; i = ne[i])
{
int v = en[i];
if(!dfn[v])
{
tarjan(v);
low[u] = min(low[u], low[v]);
if(low[v] >= dfn[u])
{
scc ++ ;
int x;
do{
x = sta[tt -- ];
dcc[scc].push_back(x);
cut[x] ++ ;
}while(x != v);
dcc[scc].push_back(u);
cut[u] ++ ;
}
}
else low[u] = min(low[u], dfn[v]);
}
}
块-割点树
对于每个点双连通分量建一个节点与这个点双连通分量里的割点连边。用来处理与割点相关的问题。
圆方树
对于每个点双连通分量建一个节点与这个点双连通分量里的点连边。可以利用点双连通图的性质解决问题。
二分图相关
定理
- 增广路定理:一个图为最大匹配等价于不存在增广路。
- Hall 定理:
- 一个二分图存在完全匹配当且仅当点数较小的一侧的点,它的任意一个点集相连的点的数量大于等于点集的大小。
- 一个二分图的最大匹配为点数较小的一侧的点的点数,减去它的一个点集相连的点的数量与点集的大小的差的最小值。
- Kőnig 定理:二分图最小点覆盖等于最大匹配。构造方案:进行最大匹配之后,从左侧每个非匹配点出发,非匹配边、匹配边交替行走(即匈牙利算法实现的过程),标记能够到达的点,则左边未标记的点和右边标记的点组成一个点覆盖且数量等于最大匹配数。
- 独立集的补集为覆盖集。
- 团在补图中为独立集。
- Dilworth 定理:偏序集能划分成的最少的全序集个数等于最大反链的元素个数。在一个 DAG 中,最大的点集使两两之间没有边,等于最小路径划分。
算法
-
染色法判断二分图。
-
匈牙利算法
- 把所有点按一定顺序进行增广,时间复杂度 \(O(nm)\)。
- 在对一个点进行増广之前,这个点是非匹配点。
- 通过调整増广的顺序,可以解决一些字典序问题,如变换序列。注意:对于一般图不能直接倒序增广,考虑这个方法:先求出一个最大匹配,然后从前往后枚举每个点,枚举与它连边的点,将它原来的匹配删除,固定这两个点的匹配,判断是否可以重新增广,时间复杂度 \(O(m^2)\)。似乎可以直接断掉原来的匹配后从小到大增广,时间复杂度 \(O(nm)\)。
- 如果图要改变,可以只对影响的点进行增广。
- HK 算法
- 与 Dinic 算法类似,运用多路增广算法。
- 首先 BFS 进行分层,非匹配边方向从左往右,匹配边方向从右向左,求非匹配点到匹配点的最短路径,同时判断是否存在增广路。
- DFS 进行增广,增广路必须满足下一个点是这个点层数加 \(1\)。
- 可以证明时间复杂度 \(O(m\sqrt {n})\)。
模型
- 二分图判断。
- 最大匹配。
- 最小点覆盖。
- 最大独立集。
- DAG 的最小路径划分:拆点,对于原图中的边 \((u,v)\),新图中是 \((u,v')\),最小路径划分为 \(n-\text{最大匹配}\)。方案为:从每个右侧的非匹配点出发,对应的左侧的点,这个点的匹配,对应的左侧的点……这样构成一条路径。
- DAG 的最小路径(可重复)覆盖:求传递闭包,转化为上问题。
- DAG 的最大不可达点集:求传递闭包,转化求最大反链。
- 有向图环划分:建图方式同 DAG 的最小路径划分,存在环划分等价于二分图存在完美匹配。
- 二分图博弈:给定一张二分图和棋子初始位置,先后手交替移动棋子,并把移动前棋子所在节点删除,不能操作者失败。先手必胜等价于棋子的初始位置是在所有的最大匹配中,也就是删去这个节点后,最大匹配会变小。证明:
- 如果当前棋子位置没有边,则先手必败,此时这个节点不在任何最大匹配中。
- 如果当前棋子位置 \(u\) 在所有最大匹配中,则先手移动到任意一个它的匹配 \(v\),假设后手移动到 \(w\),且 \(w\) 在新图中不在所有最大匹配中,则可以用 \((w,v)\) 的匹配替换 \((u,v)\) 的匹配,矛盾,所以 \(w\) 一定在所有最大匹配中。
- 如果当前棋子位置 \(u\) 不在所有最大匹配中,建一个新点 \(v\) 只与 \(u\) 连边,由于 \(u\) 不在所有最大匹配中,则一定可以存在匹配 \((u,v)\),所以 \(u\) 不能到达不在所有最大匹配中的点。