记录序列就是某棵生成树的先根序:固定树后孩子升序即字典序最小;基环树枚举被删的那条环边,各跑一次贪心取最小。
OJ: roj
题目 ID: 3661
难度:提高
标签:图论树DFS贪心python
创建: 2026-10-02 13:58
更新: 2026-10-10 11:30
形式化题目
给定
- 任选一个城市作起点,起点的编号记入序列;
- 每一步可以沿一条道路走到一个没去过的城市,把它的编号记入序列;
- 或者沿"第一次进入当前城市时经过的那条道路"原路后退——后退不记录;
- 回到起点时可以结束、也可以继续走,但旅行结束时每个城市都必须被访问过。
求所有合法旅行方案产生的记录序列中,字典序最小的那一个。
题面样例 2(
graph LR
A((1)) --- B((3))
C((2)) --- B
C --- D((5))
B --- E((4))
E --- D
E --- F((6))
linkStyle 1,2,3,4 stroke:#d33,stroke-width:2px
两个样例的正确走法:样例 1 是树,从 1 出发总走最小未访问邻居——1 3 2 5 4 6。样例 2 中,若在城市 2 处仍走最小未访问邻居 5,得到 1 3 2 5 4 6;而最优方案要求在 2 处空手后退回 3,再走 1 3 2 4 5 6。这个“不肯后退”的陷阱正是本题的难点。
正解
思路
朴素想法。 从 1 出发,每步走向编号最小的未访问邻居,走投无路时沿第一次进入的边后退。样例 1 上它直接正确,样例 2 上它在城市 2 处走进 5,得到次优的 1 3 2 5 4 6。
瓶颈:环上"谁是孩子"没有定下来。 旅行里有一种自由度:当前城市某个没去过的邻居,这一次不进,等以后从别的道路进入——树上不存在这种选择(孩子的子树只能经它进入,必须当场走完),环上才有。所以不能无条件贪心,必须把这个选择显式枚举。
观察一:记录序列 = 某棵生成树的先根序。 每个城市(除起点外)第一次被进入时经过一条边,这些边互不相同、恰
观察二:固定树后,孩子升序即字典序最小。 先根序里,每个孩子及其子树是一段连续块,块首元素就是孩子编号本身。把块按块首升序拼接,第一个不同位置落在第一个不同的块首上,取值最小——对深度归纳可知每个点都让孩子升序访问。这等价于"总走最小未访问邻居"的贪心,一次
观察三:起点必为 1。 序列首元素就是起点,
观察四:候选生成树只有
四条观察合起来:起点取 1,对每棵候选生成树跑一次“孩子升序”贪心得到先根序,取字典序最小者。
为什么这样就对(两头夹)。 一方面,每个候选序列都真实可走(沿树前进、沿父边后退),所以它们的最小值仍是合法方案;另一方面,任取一个合法方案,它的首次进入边集必是某棵候选树,而该方案的序列按观察二不会优于这棵树的升序先根序,后者又不小于所有候选的最小值。两头一夹,输出既合法又不劣于任何合法方案,恰为字典序最小。
样例 2 推演。 下表把 4 条环边依次删去、各跑一次升序贪心(起点均为 1):
| 删掉的环边 | 升序贪心得到的序列 |
|---|---|
| 1 3 4 5 2 6 | |
| 1 3 2 5 4 6 | |
| 1 3 2 5 4 6 | |
| 1 3 2 4 5 6 |
最后一行最小,与答案一致。关键在删掉
算法流程。 读入建邻接表并逐点升序排序(升序贪心就是顺序扫描);
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
vector<pair<int,int> > find_cycle(vector<vector<int> >&adj){int n=adj.size()-1; vector<int> parent(n+1), seen(n+1); seen[1]=1; vector<pair<int,int> > st; st.push_back({1,0}); while(!st.empty()){int u=st.back().first, i=st.back().second; if(i>=(int)adj[u].size()){st.pop_back(); continue;} st.back().second=i+1; int v=adj[u][i]; if(v==parent[u]) continue; if(seen[v]){vector<int> path; path.push_back(u); int x=u; while(x!=v){x=parent[x]; path.push_back(x);} vector<pair<int,int> > res; for(int j=0;j+1<(int)path.size();j++) res.push_back({path[j],path[j+1]}); res.push_back({u,v}); return res;} seen[v]=1; parent[v]=u; st.push_back({v,0});} return vector<pair<int,int> >();}
vector<int> tree_walk(vector<vector<int> >&adj,pair<int,int> cut){int n=adj.size()-1; int su=cut.first,sv=cut.second; vector<int> vis(n+1), seq, path(n+1), step(n+1); vis[1]=1; seq.push_back(1); path[0]=1; int top=1; while(top){int u=path[top-1]; int i=step[top-1]; if(i==(int)adj[u].size()){top--; continue;} step[top-1]=i+1; int v=adj[u][i]; if((u==su&&v==sv)||(u==sv&&v==su)) continue; if(vis[v]) continue; vis[v]=1; seq.push_back(v); path[top]=v; step[top]=0; top++;} return seq;}
int main(){ios::sync_with_stdio(false);cin.tie(nullptr); int n,m; if(!(cin>>n>>m)) return 0; vector<vector<int> > adj(n+1); for(int i=0;i<m;i++){int u,v;cin>>u>>v; adj[u].push_back(v); adj[v].push_back(u);} for(int i=1;i<=n;i++) sort(adj[i].begin(),adj[i].end()); vector<pair<int,int> > cuts; if(m==n) cuts=find_cycle(adj); else cuts.push_back({0,0}); vector<int> best=tree_walk(adj,cuts[0]); for(int i=1;i<(int)cuts.size();i++){vector<int> seq=tree_walk(adj,cuts[i]); if(seq<best) best=seq;} for(int i=0;i<(int)best.size();i++){if(i) cout<<' '; cout<<best[i];} cout<<'\n'; return 0;}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 13:58
# update_at: 2026-10-02 13:58
import sys
def find_cycle(adj: list[list[int]]) -> list[tuple[int, int]]:
"""找基环树中唯一的环:DFS 撞到已访问的非父邻居即闭环,沿 parent 链回溯取整环。"""
n = len(adj) - 1
parent = [0] * (n + 1)
seen = bytearray(n + 1)
seen[1] = 1
# 显式栈存 (节点, 下一个要检查的邻接下标),避免递归深度限制
stack: list[list[int]] = [[1, 0]]
while stack:
u, i = stack[-1]
if i >= len(adj[u]):
stack.pop()
continue
stack[-1][1] = i + 1
v = adj[u][i]
if v == parent[u]:
continue
if seen[v]: # 非父邻居已访问 → u 到 v 的 parent 链加上边 (u,v) 就是那个环
path = [u]
x = u
while x != v:
x = parent[x]
path.append(x)
return list(zip(path, path[1:])) + [(u, v)] # 环上全部边
seen[v] = 1
parent[v] = u
stack.append([v, 0])
return [] # 连通图 + m=n 必有环,走不到这里
def tree_walk(adj: list[list[int]], cut: tuple[int, int]) -> list[int]:
"""在删去 cut 边后的树上做字典序最小的 DFS:总是走向编号最小的未访问邻居,
走投无路时沿原路上退(后退不记录),返回到达城市的记录序列。"""
n = len(adj) - 1
skip_u, skip_v = cut # 被删去的环边两端;(0,0) 表示不删任何边
vis = bytearray(n + 1)
vis[1] = 1
seq = [1]
path = [0] * (n + 1) # 显式栈:当前所在的 DFS 路径
step = [0] * (n + 1) # 栈中各节点已检查到的邻接表下标
path[0] = 1
top = 1
while top:
u = path[top - 1]
adj_u = adj[u]
i = step[top - 1]
if i == len(adj_u): # 邻居全看过了:回退到上一个城市
top -= 1
continue
step[top - 1] = i + 1
v = adj_u[i]
if (u == skip_u and v == skip_v) or (u == skip_v and v == skip_u):
continue # 环边被删,不能从这里过去
if vis[v]:
continue
vis[v] = 1
seq.append(v)
path[top] = v
step[top] = 0
top += 1
return seq
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
adj: list[list[int]] = [[] for _ in range(n + 1)]
for _ in range(m):
u, v = next(data), next(data)
adj[u].append(v)
adj[v].append(u)
for adj_u in adj:
adj_u.sort() # 邻接表升序:贪心"走最小编号"就是顺序扫描
# 记录序列 = 某棵生成树的先根序;m=n 时合法生成树 = 删掉环上一条边,逐个试取最小
cuts = find_cycle(adj) if m == n else [(0, 0)]
best = tree_walk(adj, cuts[0])
for cut in cuts[1:]:
seq = tree_walk(adj, cut)
if seq < best:
best = seq
print(' '.join(map(str, best)))
if __name__ == "__main__":
solve()复杂度
- 时间:建图与逐点排序
。 :一次贪心 。 :找环 ,枚举 条环边、每次贪心扫描全图 ,合计 ,最坏 时约 次邻接扫描——与标准 C++ 正解(枚举删边)同阶。实测(本机 python3.14,数据仓 25 组真实测例):树的 测例约 0.02 s, 的环测例约 0.22 s, 的环测例约 3.5–3.9 s。OJ 限 1000 ms,Python 大测例可能 TLE(按 oj-python-style 约定允许),算法本身不受影响。 - 空间:
,邻接表、visited 与两条显式栈均为线性,实测峰值 MB(限 32 MB)。
总结
这道题的关键是把"带后退的旅行"翻译成可枚举的组合对象:记录序列就是某棵生成树的先根序,起点必为 1;固定树之后,让每个点的孩子按编号升序访问(等价于总走最小未访问邻居)就得到该树字典序最小的先根序。于是问题只剩"枚举候选生成树"——树只有 1 棵;基环树恰一个环,生成树恰是"删掉环上一条边"的
图示解析
这张图串起本题从输入到答案的主线:
输入 n 个点、m 条边(m = n-1 或 m = n)
|- 邻接表逐点升序排序 → 升序贪心 = 顺序扫描
|- m = n ? 找唯一的环:撞到已访问的非父邻居即闭合,沿 parent 回溯取环边
|- 候选生成树:树情形唯一 1 棵;环情形 = 逐条删掉环边,共 c 棵
`- 每棵候选树跑栈式贪心(总走最小未访问邻居)→ 先根序 → 取字典序最小输出先看每层分支代表的推理结论:排序服务于贪心,找环服务于候选枚举,最后一行才是真正的求值。整条路线没有搜索分支——所有"走还是退"的选择都被观察四收敛成最多
