题解:洛谷 P5022 旅行
【题目来源】
洛谷:P5022 [NOIP 2018 提高组] 旅行 - 洛谷
【题目描述】
小 Y 是一个爱好旅行的 OIer。她来到 X 国,打算将各个城市都玩一遍。
小 Y 了解到,X 国的 \(n\) 个城市之间有 \(m\) 条双向道路。每条双向道路连接两个城市。不存在两条连接同一对城市的道路,也不存在一条连接一个城市和它本身的道路。并且,从任意一个城市出发,通过这些道路都可以到达任意一个其他城市。小 Y 只能通过这些道路从一个城市前往另一个城市。
小 Y 的旅行方案是这样的:任意选定一个城市作为起点,然后从起点开始,每次可以选择一条与当前城市相连的道路,走向一个没有去过的城市,或者沿着第一次访问该城市时经过的道路后退到上一个城市。当小 Y 回到起点时,她可以选择结束这次旅行或继续旅行。需要注意的是,小 Y 要求在旅行方案中,每个城市都被访问到。
为了让自己的旅行更有意义,小 Y 决定在每到达一个新的城市(包括起点)时,将它的编号记录下来。她知道这样会形成一个长度为 \(n\) 的序列。她希望这个序列的字典序 最小,你能帮帮她吗?对于两个长度均为 \(n\) 的序列 A 和 B,当且仅当存在一个正整数 \(x\),满足以下条件时,我们说序列 A 的字典序小于 B。
- 对于任意正整数 \(1≤i<x\),序列 A 的第 \(i\) 个元素 \(A_i\) 和序列 B 的第 \(i\) 个元素 \(B_i\) 相同。
- 序列 A 的第 \(x\) 个元素的值小于序列 B 的第 \(x\) 个元素的值。
【输入】
输入文件共 \(m+1\) 行。第一行包含两个整数 \(n,m(m≤n)\),中间用一个空格分隔。
接下来 \(m\) 行,每行包含两个整数 \(u,v(1≤u,v≤n)\) ,表示编号为 \(u\) 和 \(v\) 的城市之 间有一条道路,两个整数之间用一个空格分隔。
【输出】
输出文件包含一行,\(n\) 个整数,表示字典序最小的序列。相邻两个整数之间用一个空格分隔。
【输入样例】
6 5
1 3
2 3
2 5
3 4
4 6
【输出样例】
1 3 2 5 4 6
【核心思想】
-
问题分析:给定一个 \(n\) 个点、\(m\) 条边的无向连通图(\(m \le n\)),从任意起点出发,按照“DFS 回溯”的规则遍历所有节点,每次到达新节点时记录编号,最终形成一个长度为 \(n\) 的序列。目标是找到所有可能的遍历序列中字典序最小的那个。由于 \(m \le n\),图要么是一棵树(\(m = n-1\)),要么是基环树(\(m = n\))。对于树,从 1 出发的 DFS 序列是唯一的(按邻接点编号顺序访问即可得到最小字典序);对于基环树,需要枚举删除环上的一条边(将其变成树),分别进行 DFS 并比较序列,取字典序最小者。
-
算法选择:
- 树的情况:从节点 1 开始,对每个节点的邻接点按编号从小到大排序后 DFS,得到的序列即为全局最小字典序。
- 基环树的情况:枚举环上的每一条边,将其暂时“删除”,使图变成一棵树,然后从 1 开始 DFS 得到序列,比较所有枚举边得到的序列,取字典序最小者。
- 剪枝优化:在 DFS 过程中,当当前路径已经劣于当前最优解时,立即回溯(剪枝),减少搜索时间。
- 邻接表排序:对所有邻接表按节点编号升序排序,保证 DFS 时优先访问编号小的节点,从而在相同条件下得到更小的字典序序列。
-
关键步骤:
- 读入与建图:读入 \(n, m\) 以及 \(m\) 条无向边,存储邻接表,同时存储所有边的端点(用于枚举删除)。
- 邻接表排序:对每个节点的邻接表进行升序排序。
- 若 \(m = n-1\)(树):
- 从节点 1 开始 DFS,不跳过任何边,得到序列即为答案。
- 若 \(m = n\)(基环树):
- 枚举每条边 \((u, v)\) 作为“删除”的边:
- 重置访问标记和路径数组。
- 从节点 1 开始 DFS,在遍历时跳过 \((u, v)\) 这条边(双向均跳过)。
- 若成功遍历所有节点(路径长度为 \(n\)),则与当前最优路径比较字典序,更新最优路径。
- 枚举每条边 \((u, v)\) 作为“删除”的边:
- 输出:最终的字典序最小序列。
-
时间/空间复杂度:
- 时间复杂度:树的情况 \(O(n \log n)\)(排序);基环树情况 \(O(n^2)\),枚举 \(O(n)\) 条边,每次 DFS \(O(n)\),\(n \le 5000\),可接受。
- 空间复杂度:\(O(n + m)\),存储邻接表和辅助数组。
-
DFS + 枚举断边的核心思想:
- 基环树性质:基环树是由一个环和若干棵树组成,删除环上的任意一条边可使其变成树,DFS 序列与选择删除的边有关。
- 枚举所有可能性:由于环的大小最多为 \(n\),枚举环上每条边分别做 DFS,即可覆盖所有可能的遍历序列。
- 字典序比较:每次 DFS 得到序列后,与当前最优解逐位比较,取较小者。可通过在 DFS 过程中实时比较并剪枝来优化。
- 邻接点排序:保证 DFS 的固有顺序是“优先访问小编号节点”,这是字典序最小的必要条件。
- 适用场景:适用于“基环树上的 DFS 遍历序列优化”问题,是 NOIP 提高组的经典题目,考察图论基础与搜索剪枝技巧。
【算法标签】
提高 #DFS-图
【代码详解】
#include <bits/stdc++.h>
using namespace std;
int n, m, u, v; // n: 节点数, m: 边数, u,v: 临时存储边的两个端点
int router[5005]; // 存储当前遍历路径
int len; // 当前路径长度
int ans[5005]; // 存储最优路径
bool vis[5005]; // 标记数组,记录节点是否被访问
vector<int> ve[5005]; // 邻接表存储图结构
// 深度优先搜索(无限制版本)
void dfs_1(int x)
{
vis[x] = true; // 标记当前节点已访问
router[++len] = x; // 记录当前节点到路径中
// 遍历所有邻接节点
for (int i = 0; i < ve[x].size(); i++)
{
int tmp_v = ve[x][i];
if (vis[tmp_v]) // 跳过已访问节点
continue;
dfs_1(tmp_v); // 递归访问
}
return;
}
// 深度优先搜索(限制版本,跳过特定边)
void dfs_2(int x)
{
vis[x] = true; // 标记当前节点已访问
router[++len] = x; // 记录当前节点到路径中
// 遍历所有邻接节点
for (int i = 0; i < ve[x].size(); i++)
{
int tmp_v = ve[x][i];
// 跳过已访问节点或特定边
if (vis[tmp_v] || (x == u && tmp_v == v) || (x == v && tmp_v == u))
continue;
dfs_2(tmp_v); // 递归访问
}
return;
}
int main()
{
cin >> n >> m; // 输入节点数和边数
// 构建图的邻接表
for (int i = 1; i <= m; i++)
{
cin >> u >> v;
ve[u].push_back(v);
ve[v].push_back(u);
}
// 对每个节点的邻接表进行排序(保证按编号从小到大访问)
for (int i = 1; i <= n; i++)
sort(ve[i].begin(), ve[i].end());
// 如果是树结构(m=n-1)
if (m == n - 1)
{
dfs_1(1); // 从节点1开始深度优先搜索
for (int i = 1; i <= n; i++)
cout << router[i] << " "; // 输出遍历序列
}
else // 如果是图结构(存在环)
{
// 枚举所有可能的边
for (u = 1; u <= n; u++)
{
for (int k = 0; k < ve[u].size(); k++)
{
v = ve[u][k];
if (v <= u) // 避免重复处理无向边
continue;
len = 0;
memset(vis, 0, sizeof(vis));
dfs_2(1); // 尝试跳过当前边进行遍历
// 如果遍历了所有节点
if (len == n)
{
// 更新最优路径(字典序最小)
for (int i = 1; i <= len; i++)
{
if (!ans[i] || router[i] < ans[i])
{
memcpy(ans, router, sizeof(ans));
break;
}
if (router[i] > ans[i])
break;
}
}
}
}
// 输出最优路径
for (int i = 1; i <= n; i++)
cout << ans[i] << " ";
}
return 0;
}
// 基环树解法
#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> PII;
const int N = 5005;
int n, m; // n: 城市数, m: 道路数
vector<int> g[N]; // 邻接表存图
PII edge[N]; // 存储所有边的端点 (用于枚举删除的边)
int du, dv; // 当前枚举删除的边的两个端点
int vis[N]; // 访问标记
vector<int> path(N, N); // 存储当前 DFS 生成的序列, 初始为 N (大于任何编号)
int cnt; // 当前已访问节点数 (即 path 中有效长度)
int better; // 剪枝标记: 0-未比较, -1-当前路径已优于已存路径, 1-当前路径已劣于已存路径
// DFS 遍历图, 按字典序最小的顺序访问所有节点
// 返回值: true 表示当前分支已经确定比已存路径更差, 可以剪枝
bool dfs(int u)
{
// 如果还未确定优劣 (better == 0), 则比较当前节点 u 与已存路径中对应位置的节点
if (!better)
{
if (u > path[cnt]) // 当前节点更大, 则当前路径字典序更大, 剪枝
{
return true;
}
if (u < path[cnt]) // 当前节点更小, 则当前路径更优, 标记 better = -1
{
better = -1;
}
}
vis[u] = true;
path[cnt++] = u; // 记录当前节点
// 按邻接表顺序 (已排序) 遍历所有相邻节点
for (int v : g[u])
{
if (vis[v])
{
continue; // 已访问则跳过
}
// 如果当前边是枚举要删除的边, 则跳过 (用于基环树情形)
if (v == du && u == dv)
{
continue;
}
if (u == du && v == dv)
{
continue;
}
// 递归访问子节点, 若返回 true 则剪枝
if (dfs(v))
{
return true;
}
}
return false; // 正常完成
}
int main()
{
cin >> n >> m;
// 读入所有边, 构建邻接表, 同时存储边便于枚举
for (int i = 1; i <= m; i++)
{
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
edge[i] = {a, b};
}
// 对每个节点的邻接表排序, 保证 DFS 时按编号从小到大尝试, 以得到最小字典序
for (int i = 1; i <= n; i++)
{
sort(g[i].begin(), g[i].end());
}
// 情况一: 图为树 (m == n-1), 直接 DFS 即可得到唯一最小字典序序列
if (n == m + 1)
{
dfs(1);
}
// 情况二: 图为基环树 (m == n), 枚举每一条边作为 "删除" 的边,
// 进行 DFS 得到一条遍历路径, 由于 DFS 的剪枝和路径比较,
// 最后 path 中会保留字典序最小的路径 (因为每次 DFS 都会与当前最佳比较)
else
{
for (int i = 1; i <= m; i++)
{
du = edge[i].first;
dv = edge[i].second; // 设置要跳过的边 (u, v)
memset(vis, 0, sizeof(vis)); // 重置访问标记
cnt = 0;
better = 0; // 重置比较状态
dfs(1); // 从城市 1 开始 DFS
// 注意: 每次 DFS 都会更新 path, 由于剪枝逻辑, 最终 path 保留的是最优序列
}
}
// 输出最终的字典序最小的序列
for (int i = 0; i < n; i++)
{
cout << path[i] << " ";
}
return 0;
}
【运行结果】
6 5
1 3
2 3
2 5
3 4
4 6
1 3 2 5 4 6
浙公网安备 33010602011771号