题解:洛谷 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

【核心思想】

  1. 问题分析:给定一个 \(n\) 个点、\(m\) 条边的无向连通图(\(m \le n\)),从任意起点出发,按照“DFS 回溯”的规则遍历所有节点,每次到达新节点时记录编号,最终形成一个长度为 \(n\) 的序列。目标是找到所有可能的遍历序列中字典序最小的那个。由于 \(m \le n\),图要么是一棵树(\(m = n-1\)),要么是基环树(\(m = n\))。对于树,从 1 出发的 DFS 序列是唯一的(按邻接点编号顺序访问即可得到最小字典序);对于基环树,需要枚举删除环上的一条边(将其变成树),分别进行 DFS 并比较序列,取字典序最小者。

  2. 算法选择

    • 树的情况:从节点 1 开始,对每个节点的邻接点按编号从小到大排序后 DFS,得到的序列即为全局最小字典序。
    • 基环树的情况:枚举环上的每一条边,将其暂时“删除”,使图变成一棵树,然后从 1 开始 DFS 得到序列,比较所有枚举边得到的序列,取字典序最小者。
    • 剪枝优化:在 DFS 过程中,当当前路径已经劣于当前最优解时,立即回溯(剪枝),减少搜索时间。
    • 邻接表排序:对所有邻接表按节点编号升序排序,保证 DFS 时优先访问编号小的节点,从而在相同条件下得到更小的字典序序列。
  3. 关键步骤

    • 读入与建图:读入 \(n, m\) 以及 \(m\) 条无向边,存储邻接表,同时存储所有边的端点(用于枚举删除)。
    • 邻接表排序:对每个节点的邻接表进行升序排序。
    • \(m = n-1\)(树)
      • 从节点 1 开始 DFS,不跳过任何边,得到序列即为答案。
    • \(m = n\)(基环树)
      • 枚举每条边 \((u, v)\) 作为“删除”的边:
        • 重置访问标记和路径数组。
        • 从节点 1 开始 DFS,在遍历时跳过 \((u, v)\) 这条边(双向均跳过)。
        • 若成功遍历所有节点(路径长度为 \(n\)),则与当前最优路径比较字典序,更新最优路径。
    • 输出:最终的字典序最小序列。
  4. 时间/空间复杂度

    • 时间复杂度:树的情况 \(O(n \log n)\)(排序);基环树情况 \(O(n^2)\),枚举 \(O(n)\) 条边,每次 DFS \(O(n)\)\(n \le 5000\),可接受。
    • 空间复杂度:\(O(n + m)\),存储邻接表和辅助数组。
  5. 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
posted @ 2026-09-03 14:15  团爸讲算法  阅读(9)  评论(0)    收藏  举报