[省选联考 2020 B 卷] 丁香之路
TODO: 理解题目,理解证明
样例图解
题目解析
如果你看到一个大神在赛场上秒切了这道题,不是因为他现场推导出了欧拉路的性质,也不是因为他天赋异禀现场发明了 MST。
真相是:他的大脑里存了几百个“模型”,这道题对他来说,只是把“模型 A”和“模型 B”拼在了一起。
对于这道题,高手的思维路径是这样的(全是条件反射):
- 看到“必经每条边至少一次”
条件反射:这是“中国邮递员问题”的变种,核心是欧拉回路。 - 看到“可以重复经过”
条件反射:重复经过 = 加边。 - 看到“欧拉回路不存在”
条件反射:欧拉回路两大铁律是连通和度数。我要去修补这两个条件。 - 看到“数轴/线性结构”
条件反射:线性的连通性通常和相邻节点有关。
你之所以觉得是“从头推导”,是因为你的“工具箱”里还没有存放这些“条件反射”的触发器。
思考1
我无法理解这个题目是如何从原始的题面上转移思考到 : 构造最小权值的欧拉路径, 我发现:
- 必经路是可以重复走的, 这和欧拉路 每条边 只能走一次,不是冲突吗?
PS: 因为这儿题目是 中国邮递员问题的变种: 参考 【刘田 集合论与图论】第30讲 中国邮递员问题、货郎问题_哔哩哔哩_bilibili 所有你可以先看, 中国邮递员问题 是如何 转换成的 欧拉路问题的 然后就涉及到欧拉路充要条件: 1. 度 2. 连通
这是一个非常棒的、直击本质的疑问!
你说得很对:单纯的“欧拉路径”定义确实是每条边“恰好经过一次”。
而题目要求的是:必经边“至少经过一次”。
这看起来是矛盾的,但通过图论建模的视角,它们在本质上是统一的。我们来一步步拆解这个思维跃迁的过程。
1. 核心转换思维:“走两遍” 等价于 “加一条边”
在图论中,如果我们在一张图上行走,如果你需要重复经过某条边
在
- 物理动作:你从
走到 (第1次),后来又从 走到 (第2次)。 - 数学模型:图中存在两条边,一条是
,另一条是 。你的路径是先走 ,后走 。
结论:
题目说的“必经路可以重复走”,转化到构造模型上,就是:我们可以向图中任意添加边(虚边),只要付出对应的代价
于是问题变成了:
在原有的
条必经边的基础上,添加最少权值的额外边,使得这个新图(原边+加边) 存在一条欧拉路径(一笔画)。 只要新图存在欧拉路径,我们沿着这条路径走,就自然满足了:
- 所有必经边都走到了(因为它们在图里)。
- 额外加的边也走到了(这代表我们在路上为了连通或赶路而走的路)。
- 每条边(无论是必经的还是新加的)都“恰好”被走了一次。
2. 为什么一定是构造“欧拉路径”?
我们想求从
试想,你把所有必须走的边画在纸上。现在你的笔在
你的任务是:必须画过所有黑色的线(必经边),笔不能离开纸面,最后停在
为了让笔画不中断且总长度最短,最终画出来的图形(黑色+红色)必须满足一笔画(欧拉路径)的充要条件:
- 连通性:所有线条必须连在一起,不能有孤岛。(如果不连通,笔就必须提起来,飞过去,这在题目里意味着要走一段路过去,也就是加边)。
- 度数(奇偶性):
- 起点
和 终点 必须是“进出不平衡”的(度数为奇数)。 - 中间所有点必须是“有进必有出”的(度数为偶数)。
- 起点
所以,题目的解法就顺理成章地变成了:
如何用最小的代价(加边),把一个原本支离破碎、度数乱七八糟的图,修补成一个连通的、度数符合要求的欧拉图。
3. 解析“重复走”在算法中的体现
在我们的代码逻辑(以及木xx木大的解析)中,有两个步骤体现了“重复走/加边”的逻辑:
第一步:修正奇偶性(Greedy)
当我们连接两个奇度点
- 物理意义:我们需要从
走到 (或者反过来),这一段路是额外走的。 - 对必经边的影响:这段路可能覆盖了某些必经边,也可能是一条全新的空路。但在欧拉图模型里,它就是一条新边。
第二步:修正连通性(MST)
当我们发现两个连通块不连通时,我们添加了一条边,且权值乘了 2。
- 物理意义:我们需要从一个团走到另一个团,办完事还得回来(因为要保持度数偶数性质不变)。
- 这就是“重复走”的极致体现:比如为了去连通远方的一个必经边,我必须走过去,再走回来。这在图中等价于加了两条重边(或者说一条往返边)。
4. 总结:思维导图
-
原始需求:走完必经边,可以重复,求最短路。
-
建模转化:“重复走” = “加新边”。
-
目标重塑:构建一个包含“必经边”和“新边”的新图。
-
约束条件:为了能一气呵成走完(一笔画),新图必须满足欧拉路径条件(连通 + 奇偶)。
-
算法落地:
- 先补奇偶(成本最低,因为只加1条边)。
- 再补连通(成本稍高,因为要往返,加2条边)。
一句话点醒梦中人:
欧拉路径要求每条边只走一次,是指最终构造出来的那个完美图里的每条边只走一次。而那个“完美图”里,包含了必经边(走一次)和我们额外添加的边(走一次,代表现实中的重复路或赶路)。
疑问
贪心算法原本的逻辑跑完,我们得到的是一个闭环(欧拉回路),而不是一条从
- 为什么一定求 欧拉路 ,欧拉回路 不可以吗? 2. 如果原来 的必走边,已经形成的欧拉回路, 然后添加 s ->i 的虚拟边, 变成奇数的度, 然后 贪心算法,就会添加一下边, 这样不久产生 额外的花费了吗?
- 如果 s 与 i 是同一个点, 那不就是欧拉回路吗? 那么添加 虚拟的 s->i 的边 ,还正确吗
关于
虚拟增加度数的三个核心疑问证明
1. 为何必须求欧拉路径(非回路)?
- 约束:题目要求起点为
,终点为 。 - 证明:若
,欧拉回路性质决定必须回到起点 ,无法停在 。仅当 度数为奇,其余为偶(欧拉路径条件)时,才能保证一笔画路径终于 。
2. 原图若已连通,虚拟边导致的加边是否为“冤枉钱”?
- 否,这是必要代价。
- 证明:若原必经边构成回路(
),任务却要求停在 。虚拟操作 deg[S]++, deg[i]++制造了奇点,迫使贪心算法添加一条实边。 - 物理意义:代表在走完回路回到
后,必须再走最短路到达终点 。该花费是满足边界条件的最小必要开销。
3. 若
- 否,逻辑自洽。
- 证明:操作等价于
deg[S] += 2。根据同余性质,度数奇偶性不变。 - 结论:算法对此情况会完全忽略(或保持原有的配对逻辑),自动兼容欧拉回路求解,无需特判。
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/**
* [题解] P6628 丁香之路
* 基于严谨的“奇偶性修正 -> 连通性修正”两步法
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2505;
// MST 的边结构体
struct Edge {
int u, v, w;
bool operator<(const Edge &other) const {
return w < other.w;
}
};
int n, m, s;
int deg[MAXN]; // 初始度数
int fa[MAXN]; // 并查集数组
int block_id[MAXN]; // 记录只包含必经边的初始连通块ID
ll base_weight = 0; // 必经边的基础权值和
// 并查集查找
int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
// 并查集合并
void unite(int x, int y) {
int fx = find(x);
int fy = find(y);
if (fx != fy) fa[fx] = fy;
}
void init() {
cin >> n >> m >> s;
for (int i = 1; i <= n; ++i) fa[i] = i;
for (int i = 1; i <= m; ++i) {
int u, v;
cin >> u >> v;
base_weight += abs(u - v);
deg[u]++;
deg[v]++;
unite(u, v); // 初始必经边合并
}
// 保存初始的连通状态,避免每次循环都重新跑m条边的合并
for (int i = 1; i <= n; ++i) {
block_id[i] = find(i);
}
}
void solve() {
// 枚举每一个点作为终点 i
for (int i = 1; i <= n; ++i) {
// --- 0. 状态重置 ---
// 每次计算基于 block_id 重建并查集
for (int k = 1; k <= n; ++k) fa[k] = k; // 这里 fa 维护的是 block_id 之间的关系
// 复制一份度数,避免修改原数组
vector<int> cur_deg(n + 1);
for(int k=1; k<=n; ++k) cur_deg[k] = deg[k];
// 虚拟增加起点和终点度数
cur_deg[s]++;
cur_deg[i]++;
// 初始时,起点s和终点i所在的块必须视为已有了某种关系(虽不一定连通,但在逻辑上纳入考量)
// 实际上这一步在下面的奇偶修正或MST中会自动处理,但为了逻辑严密,
// 我们先把 s 和 i 所在的初始块标记为“通过路径连接”
// 这里不需要显式 unite,因为后面连通性检查会覆盖。
ll current_ans = base_weight;
// --- 1. 奇偶性修正 (Parity Fix) ---
// 找出所有奇数度的点
vector<int> odds;
for (int k = 1; k <= n; ++k) {
if (cur_deg[k] % 2 != 0) odds.push_back(k);
}
// 相邻配对
for (size_t k = 0; k < odds.size(); k += 2) {
int u = odds[k];
int v = odds[k+1];
current_ans += (v - u); // 加上距离花费
// 【关键】:连接 u, v 意味着区间 [u, v] 内所有的连通块都被串联了
// 我们遍历 u 到 v 之间的所有点,把它们所在的初始块(block_id)都合并起来
int root_u = find(block_id[u]);
for (int x = u + 1; x <= v; ++x) {
int root_x = find(block_id[x]);
if (root_u != root_x) {
fa[root_x] = root_u; // 合并
}
}
}
// --- 2. 连通性修正 (MST) ---
// 经过上面的操作,可能还有若干个大的连通块是断开的
// 我们需要用 Kruskal 把它们连起来,边的代价是 2 * dist
vector<Edge> edges;
int pre = 0; // 上一个“有度数的点”或“被涉及到的点”
for (int k = 1; k <= n; ++k) {
// 如果点 k 有度数(必经边涉及点 或 s 或 i),或者它在奇偶修正中被连上了
// 判断方法:只要 cur_deg > 0 或者它被包含在某个连通分量里
// 更简单的判断:我们只关心那些“有初始度数”的点形成的块之间的连接
// 因为奇偶修正已经把中间空的都连上了。
// 严谨判断:我们需要连接的是所有“非空”的连通块。
// 只要 cur_deg[k] > 0,它就是一个关键点。
if (cur_deg[k] > 0) {
if (pre != 0) {
int root_k = find(block_id[k]);
int root_pre = find(block_id[pre]);
if (root_k != root_pre) {
// 相邻两个关键点所在块不同,添加候选边
// 权值为距离,但在 MST 中实际代价是 2 * 距离
edges.push_back({root_k, root_pre, abs(k - pre)});
}
}
pre = k;
}
}
sort(edges.begin(), edges.end());
for (auto &e : edges) {
int fu = find(e.u); // e.u 已经是 block_id 的 root 了,但为了保险再 find 一次
int fv = find(e.v);
if (fu != fv) {
fa[fu] = fv;
current_ans += (ll)e.w * 2; // 必须走往返,所以 * 2
}
}
cout << current_ans << (i == n ? "" : " ");
}
cout << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
init();
solve();
return 0;
}