LeetCode 332困难高级图
重新安排行程 图解题解
这道题到底在问什么
给一堆机票(出发地→到达地),每张恰好用一次,从 JFK 出发拼出一条用光所有票的行程;若有多条,取字典序最小的那条。
- 机票
- JFK→SFO, JFK→ATL, SFO→ATL, ATL→JFK, ATL→SFO
- 输出
- JFK → ATL → JFK → SFO → ATL → SFO
最优解:一步一步想明白
- 3最直觉的想法:每一步都挑字典序最小的下一站。换一组票看得最清楚:JFK→KUL、JFK→NRT、NRT→JFK。从 JFK 贪心选最小的 KUL,一步就走到 KUL——可 KUL 没有任何出票,当场卡死,手里还攥着 JFK→NRT、NRT→JFK 两张没用(只用了 1 张 / 共 3 张)。贪心一头扎进死路,没法回头补票。
- 4正解叫 Hierholzer:每个机场的目的地按字典序排好,一路往最小的走、走过的票当场撕掉;一旦某机场再也走不动了,就把它压进答案栈。等递归全部回弹,把答案栈倒过来,就是字典序最小的一笔画。
- 5JFK: [ATL, SFO] ATL: [JFK, SFO] SFO: [ATL]先把五张票按出发地归类,每个出发地的目的地从小到大排好队。JFK 能去 ATL 和 SFO,ATL 能去 JFK 和 SFO,SFO 能去 ATL。从 JFK 起步,下一步永远先挑队首那个最小的。
- 6在 JFK,取队首 ATL,走过去并撕票站在 JFK,队首是 ATL,走过去,把 JFK 到 ATL 这张票撕掉。注意 JFK 的队列只剩 SFO 了。现在递归进入 ATL。
- 7在 ATL,取队首 JFK,撕票回到 JFK在 ATL,它的队首是 JFK(比 SFO 小),走回 JFK,撕掉 ATL 到 JFK 这张票。ATL 队列只剩 SFO。注意我们绕回了 JFK——但 JFK 还有去 SFO 的票没用,所以不是死路。
- 8在 JFK,队首只剩 SFO,撕票走向 SFO又回到 JFK,它的队列现在只剩 SFO,走过去,撕掉 JFK 到 SFO。JFK 队列空了——但 JFK 是要等所有递归回弹后最后才结算的,先继续往 SFO 钻。
- 9在 SFO,队首 ATL,撕票走向 ATL在 SFO,队首是 ATL,走过去撕掉 SFO 到 ATL。SFO 队列空了。继续钻进 ATL,它还剩最后一张 ATL 到 SFO。
- 10在 ATL,撕掉 ATL→SFO,五张票全部用光在 ATL,撕掉最后一张 ATL 到 SFO,走到 SFO。此刻五张票全部撕完,图上没有任何剩余的边。现在到 SFO,它前面已经走过、队列是空的——死路来了。
- 11SFO 走不动 → 压入答案栈 栈:[SFO]SFO 队列空、走不动了,于是把 SFO 压进答案栈。这就是 Hierholzer 的灵魂:最先卡死的机场,最先落进答案栈、最后才会被倒序输出。SFO 注定是行程的终点。
- 12退回 ATL,队列已空 → 入栈 栈:[SFO, ATL]递归回弹到刚才那个 ATL,它的票早撕光、队列空,于是 ATL 也压进答案栈。注意入栈顺序:先 SFO 后 ATL,但因为是栈,ATL 现在压在 SFO 上面。
- 13继续回弹,每层队列都空 → 逐个入栈继续往回弹:上一层的 SFO 入栈、再上层第一次到的 JFK 入栈、再上层的 ATL 入栈,最后回到起点 JFK 入栈。每一层都是因为队列已空才落地,顺序是 SFO、ATL、SFO、JFK、ATL、JFK。
- 14答案 = JFK → ATL → JFK → SFO → ATL → SFO答案栈从底到顶是 SFO、ATL、SFO、JFK、ATL、JFK,倒过来读,就是 JFK 到 ATL 到 JFK 到 SFO 到 ATL 到 SFO。一笔画完成,五张票全用光,且字典序最小。
- 17一句话记牢:目的地按字典序排队,一路撕票往最小走;谁先卡死谁先进答案栈,最后把栈倒过来读,就是字典序最小的一笔画。
- 20另一组票:JFK→KUL / JFK→NRT / NRT→JFK 贪心选小的 KUL,一步卡死用一组真会翻车的票实演:JFK→KUL、JFK→NRT、NRT→JFK。纯贪心从 JFK 选字典序更小的 KUL,一步就走到 KUL——而 KUL 没有任何出票,当场卡死,手里还剩 JFK→NRT、NRT→JFK 两张废了(只用 1 张 / 共 3 张)。正解 Hierholzer 会得到 JFK→NRT→JFK→KUL。贪心一步选小废掉回程票,这才是必须用后序回溯的真理由。
- 27学完别乱跳,去高级图专题连刷同类一笔画与图遍历题:/leetcode-animation/ds?k=graph。卡住时用 AI 助教小欧问一句“我的后序落地写成前序了吗”,它会顺着你的代码帮你定位。
⚠️ 容易写错的地方
✗ 错:走完就立刻把机场加进行程(前序)
✓ 对:走到没路才加进行程(后序)
前序加会把死路机场卡在中间,导致剩余票用不光;只有后序落地才能保证一笔画。
✗ 错:每步只贪心选最小目的地、不回溯
✓ 对:靠递归回弹自然处理死路分支
纯贪心会一头扎进死胡同(如 JFK→KUL/JFK→NRT/NRT→JFK,贪心选小的 KUL 一步卡死、废掉回程票);Hierholzer 用递归后序天然把死路排到行程末尾。
✗ 错:目的地用普通列表、忘了字典序
✓ 对:用最小堆或排好序的结构存目的地
题目要求多条路径取字典序最小,目的地不排序就拿不到最小解。
完整代码(Python / C++ / Java)
Python
class Solution:
def findItinerary(self, tickets):
from collections import defaultdict
graph = defaultdict(list)
# 逆序排序后 append,pop() 末尾即取到字典序最小
for a, b in sorted(tickets, reverse=True):
graph[a].append(b)
route = []
def dfs(airport):
while graph[airport]: # 还有票就一直走
dfs(graph[airport].pop()) # 撕票 + 递归进入
route.append(airport) # 走不动了再落地
dfs("JFK")
return route[::-1] # 后序逆序即答案C++
class Solution {
public:
vector<string> findItinerary(vector<vector<string>>& tickets) {
unordered_map<string, priority_queue<string, vector<string>, greater<string>>> g;
for (auto& t : tickets) g[t[0]].push(t[1]); // 小根堆=字典序最小先出
vector<string> route;
function<void(const string&)> dfs = [&](const string& u) {
auto& pq = g[u];
while (!pq.empty()) {
string v = pq.top(); pq.pop(); // 撕票
dfs(v);
}
route.push_back(u); // 死路落地
};
dfs("JFK");
reverse(route.begin(), route.end());
return route;
}
};Java
class Solution {
Map<String, PriorityQueue<String>> graph = new HashMap<>();
LinkedList<String> route = new LinkedList<>();
public List<String> findItinerary(List<List<String>> tickets) {
for (List<String> t : tickets)
graph.computeIfAbsent(t.get(0), k -> new PriorityQueue<>()).offer(t.get(1));
dfs("JFK");
return route;
}
private void dfs(String u) {
PriorityQueue<String> pq = graph.get(u);
while (pq != null && !pq.isEmpty()) dfs(pq.poll()); // 撕票+递归
route.addFirst(u); // 死路落地:直接插到行程头部
}
}套路模板 · 欧拉路径 Hierholzer 骨架(挖空)
# 1. 建图:每个出发点的目的地按【字典序/题目要求】排好
graph = defaultdict(____) # 列表(逆序排+pop) 或 最小堆
for a, b in edges: graph[a].___(b)
route = []
def dfs(u):
while graph[u]: # 还有未用的出边就继续
dfs(graph[u].____()) # 撕掉这条边再递归
route.append(u) # ★走不动了才后序落地★
dfs(起点)
return route[::-1] # 逆序即一笔画// 1. 建图:出边用小根堆保证字典序
unordered_map<string, priority_queue<string,vector<string>,greater<>>> g;
// for (auto& e : edges) g[e.from].push(e.to);
vector<string> route;
function<void(const string&)> dfs = [&](const string& u){
while (!g[u].empty()) { // 还有出边
auto v = g[u].top(); g[u].pop(); // 撕边
dfs(v);
}
route.push_back(u); // ★后序落地★
};
dfs(start); reverse(route.begin(), route.end());// 1. 建图:出边用 PriorityQueue 保证字典序
Map<String,PriorityQueue<String>> g = new HashMap<>();
// for (e : edges) g.computeIfAbsent(e.from,k->new PriorityQueue<>()).offer(e.to);
LinkedList<String> route = new LinkedList<>();
void dfs(String u){
PriorityQueue<String> pq = g.get(u);
while (pq != null && !pq.isEmpty()) dfs(pq.poll()); // 撕边+递归
route.addFirst(u); // ★后序落地:往头部插★
}复杂度
时间复杂度
O(E log E)
E 张票,每张恰好被撕一次(O(E)),建堆/取最小带 log E
空间复杂度
O(E)
邻接表存 E 条边 + 递归栈深度最坏 E + 答案数组 E
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 重新安排行程 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么后序逆序得到的一定是合法欧拉路径,而不会漏边?+
因为每条边只在递归里被撕一次,递归会把从某点出发能走的所有边全部走完才回弹落地。最先卡死的点是欧拉路径终点,回弹顺序正好是终点到起点,逆序即从起点到终点的一笔画。
这道题为什么用「高级图」,换最直接的暴力解会差在哪?+
高级图抓住了本题的结构特征,把暴力解里重复的工作省掉;暴力解通常要多嵌套一层枚举,数据一大就超时。具体对比见上文「暴力解及其卡点」与「最优解逐步推演」两节。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。