DSAPrep
HardAdvanced Graphs

Reconstruct Itinerary

You are given a list of airline tickets where tickets[i] = [from_i, to_i] represent the departure and the arrival airports of one flight. Reconstruct the itinerary in order and return it.

All of the tickets belong to a man who departs from "JFK", thus the itinerary must begin with "JFK". If there are multiple valid itineraries, return the itinerary that has the smallest lexical order when read as a single string.

You may assume all tickets form at least one valid itinerary. You must use all the tickets once and only once.

Example 1

Input: tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
Output: ["JFK","MUC","LHR","SFO","SJC"]

Example 2

Input: tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
Output: ["JFK","ATL","JFK","SFO","ATL","SFO"]
Explanation: Another possible reconstruction is ["JFK","SFO","ATL","JFK","ATL","SFO"], but it is larger in lexical order.

Constraints

  • 1 <= tickets.length <= 300
  • tickets[i].length == 2
  • from_i.length == 3
  • to_i.length == 3
  • from_i and to_i consist of uppercase English letters
  • from_i != to_i
View original on LeetCode ↗

Treat every airport as a graph node and every ticket as a directed edge that must be used exactly once. “Use every edge exactly once starting from a fixed node” is the definition of an Eulerian path, and the problem guarantees one exists. The lexical-smallest-itinerary requirement just means: whenever you have a choice of which edge to take next, prefer the alphabetically smallest destination.

Brute-Force Backtracking

Time O(E!) worst caseSpace O(E)

Try every unused ticket out of the current airport in lexical order, recurse, and backtrack if a choice leads to a dead end before all tickets are used. This is correct — DFS with backtracking always eventually finds a valid Eulerian path if one exists — but it can redo enormous amounts of work: a wrong greedy pick that eventually dead-ends may not be discovered until deep into the recursion, forcing the search to backtrack out of a large subtree.

from collections import defaultdict
class Solution:
def findItinerary(self, tickets: list[list[str]]) -> list[str]:
graph = defaultdict(list)
for src, dst in tickets:
graph[src].append(dst)
for city in graph:
graph[city].sort()
n = len(tickets)
used = [False] * n
path = ["JFK"]
def backtrack(city: str) -> bool:
if len(path) == n + 1:
return True
for i, (src, dst) in enumerate(tickets):
if not used[i] and src == city:
used[i] = True
path.append(dst)
if backtrack(dst):
return True
path.pop()
used[i] = False
return False
backtrack("JFK")
return path

In the worst case (a dense graph with many dead-end traps) this degenerates toward trying permutations of edges, i.e. O(E!). It passes on LeetCode’s test data because the graphs are small and forgiving, but it is not the intended solution.

Hierholzer's Algorithm (Greedy DFS + Postorder Reversal)

OptimalTime O(E log E)Space O(V + E)

The key insight fixing the brute force’s blind spot: never backtrack on a used edge — instead, record airports in postorder as you get stuck, and reverse at the end. This is Hierholzer’s algorithm for Eulerian paths. Concretely:

  1. Build an adjacency list, sorting each airport’s destinations lexically so the smallest option is tried first. Use a structure that lets you pop the smallest destination in O(log d) — a min-heap per node — or, equivalently, sort descending and pop() from the end.
  2. DFS from "JFK", consuming (popping) an edge each time you traverse it.
  3. When a node runs out of unused outgoing edges, append it to the result list and return up the call stack — this is the “dead end” that Hierholzer’s algorithm relies on being correct, not a mistake to recover from.
  4. Reverse the result at the end.

Why appending on dead-end and reversing works: every airport with equal in-degree and out-degree will always have an unused edge to leave through until the very last visit, so the recursion only “gets stuck” for good at nodes that are the true end of the walk. Any leftover sub-cycles found while stuck get spliced into the postorder naturally because the DFS resumes them before finishing the outer call.

from collections import defaultdict
class Solution:
def findItinerary(self, tickets: list[list[str]]) -> list[str]:
graph = defaultdict(list)
# Sort tickets reverse-lexically so pop() (from the end) yields
# the lexically smallest destination first — O(1) per pop.
for src, dst in sorted(tickets, reverse=True):
graph[src].append(dst)
route = []
def dfs(airport: str) -> None:
while graph[airport]:
nxt = graph[airport].pop()
dfs(nxt)
route.append(airport)
dfs("JFK")
return route[::-1]

Worked exampletickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]. Adjacency (sorted): JFK → [ATL, SFO], SFO → [ATL], ATL → [JFK, SFO].

Watch three things in this trace: how every pop grows the DFS call stack and every append shrinks it; how the emerald route collects airports in postorder, from the end of the journey backward; and the final step that reverses that route into the answer.

DFS call stack

JFK

Airports — remaining tickets

JFK

▲ nextATLSFO

ATL

▲ nextJFKSFO

SFO

▲ nextATL
route (postorder)0 / 6
empty — nothing has finished yet
1 / 13
current frame (top of call stack)edge consumedappended to routewaiting frameused ticketunused ticket

Start DFS at JFK, the airport the itinerary must begin with. Each site shows its remaining tickets as a sorted stack, next to pop on top: JFK holds ATL and SFO, ATL holds JFK and SFO, SFO holds ATL. Popping the top always takes the lexicographically smallest destination first.

Reversing gives ["JFK","ATL","JFK","SFO","ATL","SFO"], matching the expected output — the same chain the call stack spelled out at its deepest point.

Complexity: sorting the tickets costs O(E log E); the DFS itself visits each edge exactly once (O(E)), popping from the end of a sorted list in O(1) — so the whole algorithm is O(E log E) time, dominated by the sort. Space is O(V + E) for the adjacency list, recursion stack, and result.