最小树形图
前言:最小树形图实在是一道比较模板的题目,而且一般不常见,其本质是贪心算法。
小问题:在引出有向有环图的最小树形图算法前,我们先来考虑一个问题,对于有向无环图我们要怎么求它的最小树形图呢?
因为图上是没有环的,所以我们直接对于每个点(非根节点)求出它的最小入边即可。
而且因为是无环,所以根节点必须只有一个(即入度为0的节点只有一个),否则就和无环相矛盾。
1.朱刘算法(Edmonds算法)
有向图上的最小生成树(DMST)也被称为最小树形图。
使用朱刘算法可以在的时间复杂度内解决该问题。
具体流程如下:
- 对于每个点,从它所有的入边中选择边权最小的边,并记录下来。
- 如果所有选择的边没有形成环,那么DMST就求好了。如果形成环,那么把环缩成点(在此之前,需要更新图上其它边的边权),在新图上跑朱刘算法(这里的缩环并没有把所有的环缩掉,所以图上可能仍然存在环)。
这里给出算法的正确性的证明(具体参考yybakioi的题解):
简陋的证明
1.考虑对每个节点(除了root节点)都选取一条最小的入边,如果没有构成环,那么这个图一定就是最小权值的。
2.如果存在环,因为这个环是由最小入边形成的环,因此存在一个最小树形图只使得该环缺失一条边。那么应该怎么考虑缺失哪一条边?
先对该环缩环成点,然后更新缩环后的图上的边的边权【令,,如果x在环上,那么所有指向x的边都需要减去】。我们的问题变成:求缩环后的图的最小树形图。
这一步就是贪心:把环上的边的边权考虑在内,如果环外有一条边要被选上,那么对答案的贡献就是。【可以理解为先选,然后替换(带后悔的贪心)】
3.既然已经化为了新图的问题,那么就直接继续1、2过程,直到找到没有环的图即可。
2.tarjan优化朱刘算法的实现思路(可并堆优化朱刘算法)【一个推荐BLOG】
朱刘算法是O(n*m)的,因为它每一次都需要去枚举所有边来求出最小入边,导致复杂度降不下来。
那么先来看看优化算法的具体流程吧。
定义一个名词:搜索链为当前搜索到的节点的一条链。(只是笔者的一个理解方式,不是论文的原内容)
一、contract过程:。
- 第一步,对于每个节点的入边集建一个左偏树。
- 第二步,首先从图里随便选择一个节点(默认是1),把它push到搜索链末尾。
- 第三步,选择搜索链的末尾节点(有可能是缩点之后的节点),然后取出它的最小入边,设该边的另一端的节点为x。如果x不在链里,直接放在搜索链末尾即可。如果x已经在搜索链里了,那么就说明出现了环,那么就需要缩环。
- 缩环的具体过程如下:
- 1. newnode_id = circle_num++ 获取到一个新的缩点编号。
- 2. 把环上的所有节点y取出来(①记录fa[t]=newnode_id这里形成一个缩环点之间的树形结构)(②在这个过程中用nxt数组记录连接关系,在extract过程中会使用到),并且合并他们的左偏树。
- 3. 最后把新的缩点放在搜索链末尾(环上的节点在2.中已经从搜索链中pop掉了)
- 重复第三步,直到整个图被缩成一个点(这也是为甚么要提前加入n条边使得图变成强连通块)
二、extract过程:直接调用extract(root, n)函数。
上述contract过程形成了一棵树,形成的这棵树到底有什么用呢?
回想我们建这棵树的过程,我们实际上把有用的边全放在这棵树里面了,那么可以推出下面的一些性质:
1. 假设原来的点有n个,缩点有t个,那么这棵树就有n+t个点,同时有n+t-1条边,最小树形图的边一定在这些边里面。
2. 因为缩点有t个,说明环一定也有t个。(因为每次缩环过程就会产生一个缩点,两者数量相同)。
3. 最小树形图有n-1条有向边,即从n+t-1条边里面,删掉t条边(刚好从t个环里面各删一条边)。
有了上面的说明,可以来理解extract过程了(假设Top是最后缩成的点的编号)。
- 第一步,对root调用extract(root, Top)函数。(令x=root)
- 第二步,取出x所在的环(假设为c_id),加上除了x的入边之外的所有边的边权。
- 第三步,对环上的其它节点调用extract(v, c_id)函数,
- 第四步,令x=fa[x],继续第二、三步,直到x=Top就停止。
这个过程仔细思考之后,发现有点像从root开始dfs一样,把每个环都去掉环上的一条边,最后得到DMST的权值。
该算法比较巧妙,我写完这些之后还是有些不太理解的地方。。。。(这个树形结构真的太妙了,而且搜索方式也很迷)
三、区别(不同的理解方式):
从实现/思路上看,朱刘算法更像是最小生成树的博鲁夫卡算法(boruvka),而tarjan优化更像是prim算法的实现方式(每次从更新链的末尾添加一个点,看看是不是形成一个环),大概是因为优化之后是通过搜索一条路径来求环的吧。
1.固定根节点的模板代码:(之前的模板有些细节没调好--注意:在contract函数里面a==b且!Heap就退出)
查看代码
struct Edge {
int u, v;
ll w;
};
namespace Leftist {
struct Data { Edge* e; ll val; } dat[maxn]; // 数据结构体
int Top, Heap[maxn], lc[maxn], rc[maxn], dep[maxn], tag[maxn];
// maxn需要设置为m+n,因为边是重利用的,空间不大,设成2*m也行
void initHeap(int n) {
Top = 0;
for (int i = 0; i <= n; i++) Heap[i] = 0;
}
int newHeap(Edge* e) {
++Top, dat[Top] = {e, e->w}; // 把e->w赋值给val
tag[Top] = dep[Top] = lc[Top] = rc[Top] = 0;
return Top;
}
void add(int x, int v) { dat[x].val += v, tag[x] += v; }
void push_down(int x) {
if (lc[x]) add(lc[x], tag[x]);
if (rc[x]) add(rc[x], tag[x]);
tag[x] = 0;
}
int merge(int x, int y) {
if (!x || !y) return x | y;
if (dat[x].val > dat[y].val) swap(x, y);
push_down(x); // 下压标记
rc[x] = merge(rc[x], y);
if (dep[rc[x]] > dep[lc[x]]) swap(lc[x], rc[x]);
dep[x] = dep[rc[x]] + 1;
return x;
}
Data* pop(int& x) {
Data* ret = &dat[x];
x = merge(lc[x], rc[x]);
return ret;
}
}; // namespace Leftist
using namespace Leftist;
vector<vector<Edge>> inEdge;
Data* ed[maxn];
int n, m, rt, id[maxn], fa[maxn], nxt[maxn];
bool vis[maxn];
// 获取当前节点在哪一个环上, id可以预先赋值,可以不赋值
int ID(int x) { return id[x] = (!id[x] || x == id[x]) ? x : ID(id[x]); }
void contract() {
initHeap(2 * n + 1);
for (int i = 0; i <= 2 * n + 1; i++) vis[i] = 0, id[i] = i;
for (int i = 1, heap_x, heap_y; i <= n; i++) {
queue<int> q; // On建堆
for (auto& x : inEdge[i]) q.push(newHeap(&x));
while (q.size() > 1) {
heap_x = q.front(), q.pop();
heap_y = q.front(), q.pop();
q.push(merge(heap_x, heap_y));
}
Heap[i] = q.front();
}
vis[1] = 1; // 先把 1 放入更新链里
// a是当前更新链的下一个节点,b用于记录当前更新链的最后节点,用于复原
for (int a = 1, b = 1, p = 0; Heap[a]; b = a, vis[a] = 1) {
while (a == b && Heap[a]) // 取出最小权值的入边
ed[a] = pop(Heap[a]), a = ID(ed[a]->e->u); // 枚举b的最小入边
if (a == b && !Heap[a]) break; // a==b且!Heap: 已经缩点完成,退出循环
if (!vis[a]) continue; // 遇到了没遇到的新点,加入链尾
// 遇到环,缩环,++n添加一个新的点
for (a = b, ++n; a != n; a = p) { // p是下一个点的ID
id[a] = fa[a] = n;
if (Heap[a]) add(Heap[a], -ed[a]->val); // 注意,取更新后的边权add
Heap[n] = merge(Heap[n], Heap[a]); // 省空间,Heap[n]的节点用的是Heap[a]
p = ID(ed[a]->e->u);
nxt[p == n ? b : p] = a; // 记录环上下一个节点是谁
}
}
}
int extract(int x, int t) {
int ret = 0;
for (; x != t; x = fa[x]) { // 从树的叶子往上展开节点
for (int u = nxt[x], tmp; u != x; u = nxt[u]) {
// 这里u是v的祖先节点,即u是v节点所在的缩点(但是ID(v)!=u,因为到最后ID(v)=n)
if (ed[u]->e->w >= inf_int || (tmp = extract(ed[u]->e->v, u)) >= inf_int)
return inf_int;
ret += tmp + ed[u]->e->w;
// edge.emp(e); // 记录选择了这条边
}
}
return ret;
}
void solve() {
cin >> n >> m >> rt;
inEdge.assign(n + 2, {}); // 注意n+2,在不定根的时候需要开多一个(而不是n+1)
for (int i = 1, x, y, w; i <= m; i++)
cin >> x >> y >> w, inEdge[y].emp(Edge{x, y, w});
for (int i = 1; i <= n; i++)
inEdge[i % n + 1].emp(Edge{i, i % n + 1, inf_int});
contract();
ll ans = extract(rt, n);
if (ans >= inf_int) cout << -1 << endl;
else cout << ans << endl;
}
2. 不定根最小DMST模板 -- 模板题:
取一个编号作为超级源点,超级源点到其它点的距离是所有边权的sum,保证只用一条这样的边。
最后得到的结果就是不定根DMST。(注意题目是否需要在相同边权的情况下输出编号最小的root)
查看代码
struct Edge { int u, v; ll w; };
namespace Leftist { // val开成PLL可以输出最小的root编号
struct Data { Edge* e; PLL val; } dat[maxn];
int Top, Heap[maxn], lc[maxn], rc[maxn], dep[maxn], tag[maxn];
void initHeap(int n) {
Top = 0;
for (int i = 0; i <= n; i++) Heap[i] = 0;
}
int newHeap(Edge* e) {
++Top, dat[Top] = {e, {e->w, e->v}};
tag[Top] = dep[Top] = lc[Top] = rc[Top] = 0;
return Top;
}
void add(int x, int v) { dat[x].val.fi += v, tag[x] += v; }
void push_down(int x) {
if (lc[x]) add(lc[x], tag[x]);
if (rc[x]) add(rc[x], tag[x]);
tag[x] = 0;
}
int merge(int x, int y) {
if (!x || !y) return x | y;
if (dat[x].val > dat[y].val) swap(x, y);
push_down(x); // 下压标记
rc[x] = merge(rc[x], y);
if (dep[rc[x]] > dep[lc[x]]) swap(lc[x], rc[x]);
dep[x] = dep[rc[x]] + 1;
return x;
}
Data* pop(int& x) {
Data* ret = &dat[x];
x = merge(lc[x], rc[x]);
return ret;
}
}; // namespace Leftist
using namespace Leftist;
vector<vector<Edge>> inEdge;
Data* ed[maxn];
int n, m, rt, id[maxn], fa[maxn], nxt[maxn], root_v;
ll sum;
bool vis[maxn];
// 获取当前节点在哪一个环上, id可以预先赋值,可以不赋值
int ID(int x) { return id[x] = (x == id[x]) ? x : ID(id[x]); }
void contract() {
initHeap(2 * n + 1); // 需要init到 2*n+1, 避免 n=1 的情况。
for (int i = 0; i <= 2 * n + 1; i++) vis[i] = 0, id[i] = i; // 预处理并查集
for (int i = 1, heap_x, heap_y; i <= n; i++) {
queue<int> q; // On建堆
for (auto& x : inEdge[i]) q.push(newHeap(&x));
while (q.size() > 1) {
heap_x = q.front(), q.pop();
heap_y = q.front(), q.pop();
q.push(merge(heap_x, heap_y));
}
Heap[i] = q.front();
}
vis[1] = 1; // 先把 0 放入更新链里
// a是当前更新链的下一个节点,b用于记录当前更新链的最后节点,用于复原
for (int a = 1, b = 1, p = 0; Heap[a]; b = a, vis[a] = 1) {
while (a == b && Heap[a]) // 取出最小权值的入边
ed[a] = pop(Heap[a]), a = ID(ed[a]->e->u); // 枚举b的最小入边
if (a == b && !Heap[a]) break; // Heap=0且a==b时缩环结束
if (!vis[a]) continue; // 遇到了没遇到的新点,加入链尾
// 遇到环,缩环,++n添加一个新的点
for (a = b, ++n; a != n; a = p) { // p是下一个点的ID
id[a] = fa[a] = n;
if (Heap[a]) add(Heap[a], -ed[a]->val.fi); // 注意,取更新后的边权add
Heap[n] = merge(Heap[n], Heap[a]); // 省空间,Heap[n]的节点用的是Heap[a]
p = ID(ed[a]->e->u);
nxt[p == n ? b : p] = a; // 记录环上下一个节点是谁
}
}
}
int extract(int x, int t) {
int ret = 0;
for (; x != t; x = fa[x]) { // 从树的叶子往上展开节点
for (int u = nxt[x], tmp; u != x; u = nxt[u]) {
// 这里u是v的祖先节点,即u是v节点所在的缩点(但是ID(v)!=u,因为到最后ID(v)=n)
if (ed[u]->e->w >= inf_int || (tmp = extract(ed[u]->e->v, u)) >= inf_int)
return inf_int;
ret += tmp + ed[u]->e->w;
// edge.emp(e); // 记录选择了这条边
// if (ed[u]->e->u == rt) root_v = ed[u]->e->v - 1;
// 也可以在这里记录答案
}
}
return ret;
}
void solve() {
cin >> n >> m;
sum = 0;
inEdge.assign(2 + n, {}); // 因为是多一个节点的,所以需要开n+2个桶(HDU数据把我卡了)
for (int i = 1, x, y, w; i <= m; i++)
cin >> x >> y >> w, ++x, ++y, inEdge[y].emp(Edge{x, y, w}), sum += w;
rt = ++n;
for (int i = 1; i <= n; i++)
inEdge[i % n + 1].emp(Edge{i, i % n + 1, inf_int});
for (int i = 1; i < n; i++) inEdge[i].emp(Edge{rt, i, sum + 1});
contract();
ll ans = extract(rt, n) - sum - 1;
root_v = ed[nxt[rt]]->e->v - 1;
if (ans >= sum + 1 || ans >= inf_int) cout << "impossible\n\n";
else cout << ans << " " << root_v << "\n\n";
}
3.习题
(1)GGS_DDU【经典建图】
题意:一开始你每一门科目都处于0级,有m门课,(l1,c1,l2,d2,cost)表示你只有在c1这门科目达到至少l1时,才能选修这门课,修完之后,你的第d2门科目变成l2级,该课花费cost。你的任务是判断有没有解,使得所有科目达到100级,同时输出最小花费。
解析
看着题目所给的条件知道要建成有向图,但是状态不会设计。之前遇到这种题时只会使用状压表示状态,没想到此题能把所有课目分开来作为一个点。
题解:把每个课目的每个阶段当成一个节点,共不超过500个节点。一个状态能够被到达,当且仅当从源点到它有至少一条路径,由于题目要求最少花费,这条路径显然应该是权值最小的一条,而且只需要一条即可。
如果我课目a达到了阶段100,那么小于阶段100的所有节点我都是可以到达的,所以添加一条(i+1->i)的权值为0的边。最后跑一遍最小树形图,如果每个叶子节点都在图上,那么就说明计算出最小的答案,否则无解。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】