Graphs/Eulerian Tours

Lesson 6.135,701 words

Eulerian Tours

An Eulerian tour uses every edge of a graph exactly once. We give the exact parity and balance conditions under which one exists (even degree for undirected graphs, in-degree equal to out-degree for directed) and Hierholzer's O(E)O(E) algorithm that constructs one by splicing closed sub-tours.

╌╌╌╌

The traversals of the previous lessons were about reaching vertices: BFS and DFS each touch every vertex once and impose no constraint on how often an edge is used. This lesson inverts the constraint. We ask for a walk that crosses every edge exactly once: the problem Euler posed in 1736 for the seven bridges of Königsberg, and the historical seed of graph theory itself. Unlike most use everything exactly once problems, this one has a clean local characterization and a linear-time algorithm.

One contrast is central. An Eulerian tour constrains edges; its near-twin, the Hamiltonian tour, constrains vertices, visiting every vertex exactly once. The two look like mirror images, but their complexities are far apart. Deciding whether an Eulerian tour exists, and building one, takes time, as we will see. Deciding whether a Hamiltonian cycle exists is NP-complete: no polynomial algorithm is known, and finding one would settle vs (we return to intractability in a later module).1 In short: visiting every edge is easy; visiting every vertex is hard.

When does an Eulerian tour exist?

The existence test is pure local arithmetic on degrees. The intuition is a single parity argument: think of walking through the tour and watching one fixed vertex . Every time the walk passes through it arrives on one edge and leaves on another, consuming a pair of edges incident to . So the edges at an interior vertex must come in pairs.

The parity argument at one vertex: each passage through pairs an arriving edge with a departing edge, so splits into three passages and an interior vertex always has even degree.

The degree count is a decision test — does a tour exist? — so the iff is the two guarantees from the foundations at work. Necessity (every Eulerian graph satisfies the parity condition) is completeness: no graph that admits a tour is rejected by the test. Sufficiency — that the condition guarantees a tour — is soundness: when the test passes, Hierholzer's algorithm below produces an actual tour, so the yes is never empty. The witness is the proof of sufficiency.

This argument, followed carefully, is already Hierholzer's algorithm, made concrete below.

For directed graphs the parity condition sharpens into a balance condition, because each edge now has a direction: a through-passage at consumes one incoming and one outgoing edge.

Connectivity is the second, easily-forgotten half of the test: degrees can balance perfectly while the edges split into two disjoint loops, which no single walk can join.

directed path: start has , end has , balanced
balanced but disconnected: two separate even loops admit no single Eulerian tour
Eulerian path 0 or 2 odd-degree vertices

Here and each have odd degree ; the other two are even, so an Eulerian path exists and must run between and . The numbering is one valid traversal , which crosses all five edges once and stops at .

eulerian_tour.pypython
from collections import defaultdict
from collections.abc import Hashable
from typing import Generic, Optional, TypeVar
from graph import Graph


Label = TypeVar("Label", bound=Hashable)


class DegreeProfile(Generic[Label]):
  """
    The in/out (or, undirected, plain) degree summary the existence tests\n
    read. `start` is the forced first vertex of an open path, or None when\n
    the tour is closed and any incident vertex may begin it.\n
  """

  def __init__(
    self,
    has_eulerian_path: bool,
    has_eulerian_circuit: bool,
    start: Optional[Label],
  ) -> None:
    self.has_eulerian_path: bool = has_eulerian_path
    self.has_eulerian_circuit: bool = has_eulerian_circuit
    self.start: Optional[Label] = start

  def __repr__(self) -> str:
    return (
      f"DegreeProfile(path={self.has_eulerian_path}, "
      f"circuit={self.has_eulerian_circuit}, start={self.start!r})"
    )


def _connected_ignoring_isolated(graph: Graph[Label]) -> bool:
  """
    Whether all vertices that carry at least one edge lie in a single\n
    component, treating every edge as undirected. Isolated vertices are\n
    ignored — they place no constraint on an Eulerian tour.\n
  """
  # build an undirected adjacency and record which vertices carry an edge.
  adjacency: dict[Label, list[Label]] = defaultdict(list)
  active: set[Label] = set()
  for vertex in graph.vertices:
    for edge in vertex.outgoing:
      source_label: Label = edge.source.label
      target_label: Label = edge.target.label
      adjacency[source_label].append(target_label)
      adjacency[target_label].append(source_label)
      active.update((source_label, target_label))

  # an edgeless graph is vacuously connected.
  if not active:
    return True

  # flood-fill from any active vertex over the edge set.
  start: Label = next(iter(active))
  seen: set[Label] = {start}
  frontier: list[Label] = [start]
  while frontier:
    current: Label = frontier.pop()
    for neighbor in adjacency[current]:
      if neighbor not in seen:
        seen.add(neighbor)
        frontier.append(neighbor)

  # connected iff the flood reached every edge-bearing vertex.
  return seen == active


def analyze(graph: Graph[Label]) -> DegreeProfile[Label]:
  """
    Decide whether `graph` admits an Eulerian path and/or circuit, and pick\n
    a legal start vertex. Honours `graph.directed`: directed graphs use the\n
    in/out balance condition, undirected graphs use odd-degree parity.\n
    Connectivity of the edge set is required for either to hold.\n
  """
  if not _connected_ignoring_isolated(graph):
    return DegreeProfile(False, False, None)

  if graph.directed:
    return _analyze_directed(graph)
  return _analyze_undirected(graph)


def _analyze_undirected(graph: Graph[Label]) -> DegreeProfile[Label]:
  """
    Undirected test: a circuit exists iff every vertex has even degree; a\n
    path exists iff exactly 0 or 2 vertices have odd degree, and the two\n
    odd vertices are its only possible endpoints.\n
  """
  # collect the odd-degree vertices — the only legal path endpoints.
  odd_vertices: list[Label] = [
    vertex.label for vertex in graph.vertices if len(vertex.outgoing) % 2 == 1
  ]

  # no odd vertices: a circuit; start at any edge-bearing vertex.
  odd_count: int = len(odd_vertices)
  if odd_count == 0:
    first_with_edge: Optional[Label] = next(
      (vertex.label for vertex in graph.vertices if vertex.outgoing), None
    )
    return DegreeProfile(True, True, first_with_edge)

  # exactly two odd vertices: an open path starting at one of them.
  if odd_count == 2:
    return DegreeProfile(True, False, odd_vertices[0])

  return DegreeProfile(False, False, None)


def _analyze_directed(graph: Graph[Label]) -> DegreeProfile[Label]:
  """
    Directed test: a circuit exists iff in-degree equals out-degree at every\n
    vertex; a path exists iff exactly one vertex has out - in = +1 (the\n
    start), exactly one has in - out = +1 (the end), and the rest balance.\n
  """
  # tally in- and out-degree per vertex.
  in_degree: dict[Label, int] = defaultdict(int)
  out_degree: dict[Label, int] = defaultdict(int)
  for vertex in graph.vertices:
    for edge in vertex.outgoing:
      out_degree[edge.source.label] += 1
      in_degree[edge.target.label] += 1

  # a +1 surplus is the path start, -1 the end; anything else fails.
  start_candidates: list[Label] = []
  end_candidates: list[Label] = []
  for vertex in graph.vertices:
    label: Label = vertex.label
    surplus: int = out_degree[label] - in_degree[label]
    if surplus == 1:
      start_candidates.append(label)
    elif surplus == -1:
      end_candidates.append(label)
    elif surplus != 0:
      return DegreeProfile(False, False, None)

  # fully balanced: a circuit; start at any edge-bearing vertex.
  if not start_candidates and not end_candidates:
    first_with_edge = next(
      (vertex.label for vertex in graph.vertices if vertex.outgoing), None
    )
    return DegreeProfile(True, True, first_with_edge)

  # one start and one end: an open path from the surplus-out vertex.
  if len(start_candidates) == 1 and len(end_candidates) == 1:
    return DegreeProfile(True, False, start_candidates[0])

  return DegreeProfile(False, False, None)
graph.pypython
from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar


Label = TypeVar("Label", bound=Hashable)


class Edge(Generic[Label]):
  """
    A directed connection from `source` to `target`, carrying a weight.\n
  """

  def __init__(
    self,
    source: Vertex[Label],
    target: Vertex[Label],
    weight: float = 1.0,
  ) -> None:
    self.source: Vertex[Label] = source
    self.target: Vertex[Label] = target
    self.weight: float = weight

  def __repr__(self) -> str:
    return f"Edge({self.source.label!r} -> {self.target.label!r}, w={self.weight})"


class Vertex(Generic[Label]):
  """
    A graph vertex: a label plus the list of edges leaving it.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.outgoing: list[Edge[Label]] = []

  def neighbors(self) -> list[Vertex[Label]]:
    """
      The vertices reachable from this one by a single edge.\n
    """
    return [edge.target for edge in self.outgoing]

  def edge_to(self, label: Label) -> Optional[Edge[Label]]:
    """
      The outgoing edge to the vertex with `label`, or None.\n
    """
    for edge in self.outgoing:
      if edge.target.label == label:
        return edge
    return None

  def __repr__(self) -> str:
    return f"Vertex({self.label!r})"


class Graph(Generic[Label]):
  """
    A graph of Vertex objects linked by Edge objects.\n
    Pass `directed=True` for a digraph; otherwise each `add_edge` inserts\n
    the reverse edge too.\n
  """

  def __init__(self, directed: bool = False) -> None:
    self.directed: bool = directed
    self._vertices: dict[Label, Vertex[Label]] = {}

  def add_vertex(self, label: Label) -> Vertex[Label]:
    """
      Return the vertex for `label`, creating it if it is absent.\n
    """
    # reuse the existing vertex, or mint and register a fresh one.
    vertex = self._vertices.get(label)
    if vertex is None:
      vertex = Vertex(label)
      self._vertices[label] = vertex
    return vertex

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    weight: float = 1.0,
  ) -> None:
    """
      Connect two labels (creating either vertex as needed).\n
      Adds the reverse edge as well when the graph is undirected.\n
    """
    source = self.add_vertex(source_label)
    target = self.add_vertex(target_label)

    # link source to target, and mirror it back when undirected.
    source.outgoing.append(Edge(source, target, weight))
    if not self.directed:
      target.outgoing.append(Edge(target, source, weight))

  def vertex(self, label: Label) -> Vertex[Label]:
    """
      The vertex carrying `label` (raises KeyError if absent).\n
    """
    return self._vertices[label]

  @property
  def vertices(self) -> list[Vertex[Label]]:
    """
      Every vertex, in insertion order.\n
    """
    return list(self._vertices.values())

  @property
  def labels(self) -> list[Label]:
    """
      Every vertex label, in insertion order.\n
    """
    return list(self._vertices)

  def edges(self) -> Iterator[Edge[Label]]:
    """
      Each edge once — an undirected edge is yielded a single time.\n
    """
    # track undirected endpoint pairs so each is emitted only once.
    seen: set[frozenset[Label]] = set()

    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        # skip an undirected edge already yielded from the other endpoint.
        if not self.directed:
          endpoints = frozenset((edge.source.label, edge.target.label))
          if endpoints in seen:
            continue
          seen.add(endpoints)

        yield edge

  def __contains__(self, label: Label) -> bool:
    return label in self._vertices

  def __iter__(self) -> Iterator[Vertex[Label]]:
    return iter(self._vertices.values())

  def __len__(self) -> int:
    return len(self._vertices)

Hierholzer's algorithm

The natural greedy idea is almost right: start somewhere and keep walking along unused edges. Because every vertex is balanced (or you begin at an odd endpoint), you can always leave a vertex you entered, so the walk only ever gets stuck back at its starting vertex, closing a loop. But that first loop may not have covered every edge. Hierholzer's algorithm fixes this with one move: whenever a vertex on the current tour still has unused edges hanging off it, splice a fresh closed sub-tour in at that vertex, and repeat until nothing is left over.

merge closed sub-tours into one Eulerian circuit

The blue loop is a closed sub-tour discovered when the main walk reached and found unused edges. Inserting it at yields the single circuit .

Splice by splice on a concrete graph

We trace the splicing in full on a small graph. Take seven vertices and nine edges: a central triangle , a second triangle hanging off , and a third triangle hanging off . Every degree is even ( have degree ; and have degree ), the graph is connected, so an Eulerian circuit exists. Adjacency lists are kept in alphabetical order, and we start at .

Loop 1. The greedy walk from picks (first neighbor), then picks (its edge to is used), then picks . The walk is stuck at (both of 's edges are gone), having closed the sub-tour after only three of the nine edges.

Loop 2, spliced at . Vertex sits on the tour with two unused edges. The greedy walk from over unused edges gives , and inserting it where the tour visits stretches the route to , six edges.

Splices one and two: the walk from closes loop 1 (, left); vertex still has unused edges, so loop 2 () is walked and spliced in at (right), stretching the route to . The triangle at is still untouched.

Loop 3, spliced at . One vertex on the route still has unused edges: , with the untouched triangle . Walking it gives , and splicing at produces the full nine-edge circuit:

The completed circuit after splicing loop 3 (, dashed) in at : nine edges numbered in f/inal traversal order . Splicing renumbers everything after the insertion point.

Two things to observe. Splicing is insertion, so every edge after the splice point gets renumbered; the tour is best kept in a linked structure or, as next, emitted in reverse by a stack. And the splice points (, then ) were found on the existing route, which is where the connectivity hypothesis is used: any unused edge is linked to the route through the graph, so some route vertex always offers a way in.

The clean implementation does the splicing implicitly with a stack and one edge pointer per vertex (so each vertex resumes scanning where it left off, never re-examining a used edge). We push our way forward along unused edges; when a vertex runs dry, we pop it onto the output. The route is therefore emitted in reverse on backtracking, and a spliced sub-tour is naturally inserted at its shared vertex.

Algorithm:Hierholzer(G,s)\textsc{Hierholzer}(G, s) — build an Eulerian tour from start ss in O(E)O(E)
  1. 1
    for each vertex vv do
  2. 2
    ptr[v]0ptr[v] \gets 0
    next-unused-edge index
  3. 3
    stack[s]stack \gets [\,s\,];\ \ \ route[]route \gets [\,]
  4. 4
    while stackstack is nonempty do
  5. 5
    ustack.top()u \gets stack.\text{top}()
  6. 6
    if ptr[u]<adj[u]ptr[u] < |adj[u]| then
    u has an unused edge
  7. 7
    wadj[u][ptr[u]]w \gets adj[u][\,ptr[u]\,]
  8. 8
    ptr[u]ptr[u]+1ptr[u] \gets ptr[u] + 1
    consume edge u→w
  9. 9
    stack.push(w)stack.\text{push}(w)
    walk forward
  10. 10
    else
    u exhausted: back out
  11. 11
    route.append(stack.pop())route.\text{append}(stack.\text{pop}())
  12. 12
    return reverse(route)\textbf{return } reverse(route)
    emitted in reverse on backtrack

The stack in action

Running the pseudocode on the seven-vertex graph above shows how the stack performs the three splices without ever representing them explicitly. Pushes walk forward along unused edges; a pop means the top vertex is exhausted and joins the output. Reading top-of-stack as the walk's current position:

stepactionstack (bottom top) so far
1push : walk , then is exhausted
2pop
3 resumes its list: push (loop )
4pop : the whole loop is exhausted
5 resumes: push (loop )
6pop : everything drains

Reversing the final gives — exactly the circuit from the figure, with both splices threaded through their splice vertices. The mechanism: a vertex is only popped once its edges are gone, so anything discovered later from a resumed vertex (steps 3 and 5) is appended to earlier, and the reversal slots each sub-tour into the route at the right place. Step 3 is the splice at ; step 5 is the splice at ; neither required touching the part of the route already emitted.

The per-vertex pointer is what resumes its list means: when reappears at the top of the stack in step 3, it continues scanning its adjacency list from where it stopped, not from the beginning. In an undirected graph the pointer alone is not quite enough: the edge consumed from 's side must also be dead when scanned from 's side — so each undirected edge carries an id and a bit, and the scan skips ids already marked.

If the existence conditions hold, the output is a valid Eulerian tour; for an open Eulerian path, start at the unique out-excess vertex (directed) or an odd-degree vertex (undirected).3

eulerian_tour.pypython
from collections import defaultdict
from collections.abc import Hashable
from typing import Generic, Optional, TypeVar
from graph import Graph


Label = TypeVar("Label", bound=Hashable)


class DegreeProfile(Generic[Label]):
  """
    The in/out (or, undirected, plain) degree summary the existence tests\n
    read. `start` is the forced first vertex of an open path, or None when\n
    the tour is closed and any incident vertex may begin it.\n
  """

  def __init__(
    self,
    has_eulerian_path: bool,
    has_eulerian_circuit: bool,
    start: Optional[Label],
  ) -> None:
    self.has_eulerian_path: bool = has_eulerian_path
    self.has_eulerian_circuit: bool = has_eulerian_circuit
    self.start: Optional[Label] = start

  def __repr__(self) -> str:
    return (
      f"DegreeProfile(path={self.has_eulerian_path}, "
      f"circuit={self.has_eulerian_circuit}, start={self.start!r})"
    )


def hierholzer(
  graph: Graph[Label],
  start: Optional[Label] = None,
) -> Optional[list[Label]]:
  """
    Build an Eulerian tour of `graph` and return it as the sequence of\n
    vertex labels visited, or None when no Eulerian path exists.\n
    The walk has `len(edges) + 1` entries. When `start` is omitted, a legal\n
    start is chosen automatically (the forced endpoint of an open path, or\n
    any edge-bearing vertex for a closed circuit).\n
    Runs in O(E): each edge is consumed exactly once via a per-vertex\n
    pointer, and each vertex is pushed and popped a bounded number of times.\n
  """
  # bail out unless an Eulerian path exists.
  profile: DegreeProfile[Label] = analyze(graph)
  if not profile.has_eulerian_path:
    return None

  # fall back to the analyzer's legal start; no edges means the empty tour.
  if start is None:
    start = profile.start
  if start is None:
    return []

  # Build a half-edge adjacency. Each adjacency entry is (target, edge_id).
  # An undirected edge appears as a pair of half-edges sharing one id, so
  # consuming either half marks the whole edge used via `consumed[edge_id]`;
  # a directed edge gets a unique id and no twin.
  adjacency: dict[Label, list[tuple[Label, int]]] = {
    vertex.label: [] for vertex in graph.vertices
  }
  consumed: list[bool] = []

  if graph.directed:
    for vertex in graph.vertices:
      for edge in vertex.outgoing:
        edge_id: int = len(consumed)
        consumed.append(False)
        adjacency[vertex.label].append((edge.target.label, edge_id))
  else:
    # The shared Graph stores an undirected edge as two outgoing copies
    # (one per endpoint), so pair them under a single id. Visit each
    # unordered endpoint pair once and zip the two directions together —
    # this keeps parallel undirected edges as distinct, separately-consumable
    # edges, which graph.edges() would otherwise collapse.
    seen_pairs: set[frozenset[Label]] = set()
    for vertex in graph.vertices:
      for edge in vertex.outgoing:
        endpoints: frozenset[Label] = frozenset(
          (edge.source.label, edge.target.label)
        )
        if endpoints in seen_pairs:
          continue
        seen_pairs.add(endpoints)
        source_label: Label = edge.source.label
        target_label: Label = edge.target.label
        forward: int = sum(
          1
          for out in graph.vertex(source_label).outgoing
          if out.target.label == target_label
        )
        if source_label == target_label:
          forward //= 2  # a self-loop is listed twice in one outgoing list
        for _ in range(forward):
          edge_id = len(consumed)
          consumed.append(False)
          adjacency[source_label].append((target_label, edge_id))
          if source_label != target_label:
            adjacency[target_label].append((source_label, edge_id))
          else:
            # a self-loop contributes both halves to the same vertex.
            adjacency[source_label].append((target_label, edge_id))

  # per-vertex pointer so a vertex resumes scanning where it left off.
  next_edge: dict[Label, int] = defaultdict(int)
  stack: list[Label] = [start]
  route: list[Label] = []
  while stack:
    current: Label = stack[-1]
    half_edges: list[tuple[Label, int]] = adjacency.get(current, [])
    pointer: int = next_edge[current]

    # skip over half-edges already consumed from the twin side.
    while pointer < len(half_edges) and consumed[half_edges[pointer][1]]:
      pointer += 1
    next_edge[current] = pointer

    if pointer < len(half_edges):
      target, edge_id = half_edges[pointer]
      next_edge[current] = pointer + 1
      consumed[edge_id] = True  # consume the whole edge (both halves)
      stack.append(target)  # walk forward
    else:
      route.append(stack.pop())  # vertex exhausted: emit on backtrack

  route.reverse()
  return route


def is_eulerian_tour(
  graph: Graph[Label],
  tour: list[Label],
) -> bool:
  """
    Verify that `tour` walks `graph` using every edge exactly once. Edges\n
    are matched as a multiset, so parallel edges are handled correctly.\n
  """
  # tally the available half-edges as a directed multiset.
  available: dict[tuple[Label, Label], int] = defaultdict(int)
  half_edge_count: int = 0
  for vertex in graph.vertices:
    for edge in vertex.outgoing:
      available[(edge.source.label, edge.target.label)] += 1
      half_edge_count += 1

  # an undirected edge is stored as two half-edges; a directed one as a single
  # half-edge, so the true edge count differs by a factor of two.
  total_edges: int = half_edge_count if graph.directed else half_edge_count // 2

  # a valid tour visits exactly edge-count + 1 vertices (empty graph: empty).
  if len(tour) != total_edges + 1:
    return len(tour) == 0 and total_edges == 0

  # walk each step, spending its half-edge (and the twin when undirected).
  for step in range(len(tour) - 1):
    move: tuple[Label, Label] = (tour[step], tour[step + 1])
    if available[move] <= 0:
      return False
    available[move] -= 1
    if not graph.directed:
      reverse_move: tuple[Label, Label] = (tour[step + 1], tour[step])
      available[reverse_move] -= 1
  return True
graph.pypython
from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar


Label = TypeVar("Label", bound=Hashable)


class Edge(Generic[Label]):
  """
    A directed connection from `source` to `target`, carrying a weight.\n
  """

  def __init__(
    self,
    source: Vertex[Label],
    target: Vertex[Label],
    weight: float = 1.0,
  ) -> None:
    self.source: Vertex[Label] = source
    self.target: Vertex[Label] = target
    self.weight: float = weight

  def __repr__(self) -> str:
    return f"Edge({self.source.label!r} -> {self.target.label!r}, w={self.weight})"


class Vertex(Generic[Label]):
  """
    A graph vertex: a label plus the list of edges leaving it.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.outgoing: list[Edge[Label]] = []

  def neighbors(self) -> list[Vertex[Label]]:
    """
      The vertices reachable from this one by a single edge.\n
    """
    return [edge.target for edge in self.outgoing]

  def edge_to(self, label: Label) -> Optional[Edge[Label]]:
    """
      The outgoing edge to the vertex with `label`, or None.\n
    """
    for edge in self.outgoing:
      if edge.target.label == label:
        return edge
    return None

  def __repr__(self) -> str:
    return f"Vertex({self.label!r})"


class Graph(Generic[Label]):
  """
    A graph of Vertex objects linked by Edge objects.\n
    Pass `directed=True` for a digraph; otherwise each `add_edge` inserts\n
    the reverse edge too.\n
  """

  def __init__(self, directed: bool = False) -> None:
    self.directed: bool = directed
    self._vertices: dict[Label, Vertex[Label]] = {}

  def add_vertex(self, label: Label) -> Vertex[Label]:
    """
      Return the vertex for `label`, creating it if it is absent.\n
    """
    # reuse the existing vertex, or mint and register a fresh one.
    vertex = self._vertices.get(label)
    if vertex is None:
      vertex = Vertex(label)
      self._vertices[label] = vertex
    return vertex

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    weight: float = 1.0,
  ) -> None:
    """
      Connect two labels (creating either vertex as needed).\n
      Adds the reverse edge as well when the graph is undirected.\n
    """
    source = self.add_vertex(source_label)
    target = self.add_vertex(target_label)

    # link source to target, and mirror it back when undirected.
    source.outgoing.append(Edge(source, target, weight))
    if not self.directed:
      target.outgoing.append(Edge(target, source, weight))

  def vertex(self, label: Label) -> Vertex[Label]:
    """
      The vertex carrying `label` (raises KeyError if absent).\n
    """
    return self._vertices[label]

  @property
  def vertices(self) -> list[Vertex[Label]]:
    """
      Every vertex, in insertion order.\n
    """
    return list(self._vertices.values())

  @property
  def labels(self) -> list[Label]:
    """
      Every vertex label, in insertion order.\n
    """
    return list(self._vertices)

  def edges(self) -> Iterator[Edge[Label]]:
    """
      Each edge once — an undirected edge is yielded a single time.\n
    """
    # track undirected endpoint pairs so each is emitted only once.
    seen: set[frozenset[Label]] = set()

    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        # skip an undirected edge already yielded from the other endpoint.
        if not self.directed:
          endpoints = frozenset((edge.source.label, edge.target.label))
          if endpoints in seen:
            continue
          seen.add(endpoints)

        yield edge

  def __contains__(self, label: Label) -> bool:
    return label in self._vertices

  def __iter__(self) -> Iterator[Vertex[Label]]:
    return iter(self._vertices.values())

  def __len__(self) -> int:
    return len(self._vertices)

Path versus circuit

The circuit and the open path differ only in bookkeeping, but the bookkeeping trips people up, so we spell it out.

  • Start vertex. For a circuit, any vertex with an edge works, since the tour is cyclic and can be rotated to begin anywhere. For an open path the start is forced: an odd-degree vertex (undirected) or the unique vertex with (directed). Starting Hierholzer at a balanced vertex when two odd vertices exist produces a walk that gets stuck somewhere other than its start, and the stack output is not a valid path.
  • The virtual-edge reduction. The path case reduces to the circuit case: join the two odd vertices by a temporary edge (directed: add from the end-excess vertex to the start-excess one). All degrees become even (balanced), run the circuit algorithm, then delete the virtual edge from the resulting cyclic tour — the circuit breaks at exactly that point into an Eulerian path from to . This is often cleaner than special-casing the start, and it is the proof of the path half of the existence theorem.
  • Zero odd vertices still allows an open path. A graph whose vertices are all even has an Eulerian circuit, and any circuit read from any starting point is also an Eulerian path. Exactly or odd vertices in the theorem covers both.

Pitfalls

  • Quadratic edge deletion. The textbook description says walk along an edge and delete it. Deleting from the middle of an adjacency list by scanning costs degree-of- per step, and a high-degree hub turns the whole run into — on a star-like multigraph with parallel edges this is the difference between milliseconds and minutes. To address this, use the per-vertex pointer from the pseudocode: each list is scanned once, left to right, never compacted. Undirected graphs additionally need the shared-edge bit so the twin copy is skipped in when the pointer reaches it.
  • Connectivity over the wrong vertex set. The correct hypothesis is that the edges form one connected component; vertices with no edges at all are irrelevant. A connectivity check run over all of rejects valid inputs, e.g. an airport list where some airports appear in no ticket. Run DFS from any endpoint of any edge and demand that it reach every vertex of nonzero degree.
  • Degrees balance, edges do not connect. The converse trap: two disjoint even cycles pass every degree test yet admit no single tour (the figure in the existence section). Both halves of the test are necessary; skipping the connectivity half is a classic wrong-answer-on-hidden-tests bug.
  • Parallel edges and self-loops are legal. Eulerian theory applies to multigraphs — Königsberg itself has parallel bridges. Representations keyed on vertex pairs (a set of (u, v) tuples, or an adjacency matrix) silently merge parallel edges; store edge ids in the adjacency lists instead. A self-loop adds to its vertex's degree and never breaks parity.
  • Recursion depth. The recursive formulation of Hierholzer recurses once per edge; at that overflows default stacks in most languages. The explicit-stack version above is the same algorithm without that failure mode.

Applications

The pattern use every edge once is more common than it first appears. Reconstruct Itinerary asks for a flight schedule that uses every ticket exactly once: an Eulerian path on the directed multigraph of airports, where the problem's lexicographically-smallest requirement is met by keeping each vertex's outgoing destinations in sorted order and letting Hierholzer consume them in that order. De Bruijn sequences are the standout application: to find a shortest cyclic string containing every length- string over an alphabet exactly once (the Cracking the Safe problem), build the de Bruijn graph whose vertices are -grams and whose edges are -grams; that graph is balanced by construction, so an Eulerian circuit spells out the optimal sequence.

Consider the smallest case. For binary strings of length , the de Bruijn graph has two vertices, the -grams and , and four edges, the -grams . Every vertex is balanced (in-degree out-degree ), so an Eulerian circuit exists; following and reading the new symbol off each edge spells the de Bruijn sequence , a length- cyclic string in which all four -bit patterns appear exactly once:

The binary de Bruijn graph (vertices ; edges = the four -grams). Its Eulerian circuit spells the cyclic de Bruijn sequence .

The same idea drives DNA fragment assembly, where overlapping reads become edges of a de Bruijn graph and an Eulerian path stitches the genome back together, a problem that, posed instead as a Hamiltonian path over the reads, would be intractable.

reconstruct_itinerary.pypython
from collections import defaultdict
from typing import Optional, Sequence

def reconstruct_itinerary(
  tickets: Sequence[tuple[str, str]],
  origin: str = "JFK",
) -> Optional[list[str]]:
  """
    The lexicographically smallest itinerary that uses every ticket once and\n
    departs from `origin`, or None when the tickets admit no such route.\n
    Each ticket is a `(from_airport, to_airport)` pair; duplicate tickets are\n
    parallel edges and are honoured.\n
  """
  if not tickets:
    return [origin]

  # destinations per airport, sorted so the smallest is taken first.
  destinations: dict[str, list[str]] = defaultdict(list)
  for departure, arrival in tickets:
    destinations[departure].append(arrival)
  for departure in destinations:
    # reverse-sort so popping from the back yields ascending order cheaply.
    destinations[departure].sort(reverse=True)

  route: list[str] = []
  stack: list[str] = [origin]
  while stack:
    current: str = stack[-1]
    remaining: list[str] = destinations[current]
    if remaining:
      stack.append(remaining.pop())  # walk the smallest unused ticket
    else:
      route.append(stack.pop())  # airport exhausted: emit on backtrack

  route.reverse()

  # a true itinerary spends every ticket; a short walk got stuck early.
  if len(route) != len(tickets) + 1:
    return None

  # tally the ticket multiset so each leg can be checked off as used.
  unused: dict[tuple[str, str], int] = defaultdict(int)
  for ticket in tickets:
    unused[ticket] += 1

  # confirm every consecutive leg is a genuine, still-unused ticket.
  for step in range(len(route) - 1):
    leg: tuple[str, str] = (route[step], route[step + 1])
    if unused[leg] <= 0:
      return None
    unused[leg] -= 1

  return route
de_bruijn_sequence.pypython
from collections import defaultdict
from typing import Sequence

def de_bruijn_sequence(alphabet: Sequence[str], order: int) -> str:
  """
    A de Bruijn sequence B(alphabet, order): a cyclic string of length\n
    `len(alphabet) ** order` in which every length-`order` string over\n
    `alphabet` occurs exactly once as a contiguous (cyclic) window.\n
    `order` must be at least 1 and `alphabet` non-empty.\n
  """
  symbols: list[str] = list(alphabet)
  if order < 1:
    raise ValueError("order must be at least 1")
  if not symbols:
    raise ValueError("alphabet must be non-empty")

  if order == 1:
    # every single symbol is its own window; concatenation suffices.
    return "".join(symbols)

  # vertices are (order-1)-grams; an edge labelled by symbol `s` runs from a
  # gram to its suffix-plus-s. Each vertex has out-degree |alphabet|, so the
  # graph is balanced and an Eulerian circuit always exists.
  outgoing: dict[str, list[str]] = defaultdict(list)
  prefix_length: int = order - 1
  for gram in _all_grams(symbols, prefix_length):
    for symbol in symbols:
      outgoing[gram].append(symbol)

  start: str = symbols[0] * prefix_length
  edge_symbols: list[str] = _eulerian_edge_symbols(outgoing, start)

  # the circuit returns to `start`; reading one symbol per edge spells a
  # cyclic string of exactly |alphabet| ** order symbols.
  return "".join(edge_symbols)

def _all_grams(symbols: list[str], length: int) -> list[str]:
  """
    Every string of the given `length` over `symbols`, in lexicographic\n
    order relative to the symbols' own order.\n
  """
  grams: list[str] = [""]
  for _ in range(length):
    grams = [gram + symbol for gram in grams for symbol in symbols]
  return grams

def _eulerian_edge_symbols(
  outgoing: dict[str, list[str]],
  start: str,
) -> list[str]:
  """
    Hierholzer's circuit walk on the de Bruijn graph, returning one symbol\n
    per traversed edge. A vertex's successor along symbol `s` is its suffix\n
    followed by `s`, so the new symbol of each step is exactly the last\n
    character of the vertex stepped into.\n
  """
  next_edge: dict[str, int] = defaultdict(int)
  stack: list[str] = [start]
  circuit: list[str] = []
  while stack:
    current: str = stack[-1]
    choices: list[str] = outgoing[current]
    pointer: int = next_edge[current]
    if pointer < len(choices):
      symbol: str = choices[pointer]
      next_edge[current] = pointer + 1
      successor: str = (current[1:] + symbol) if current else symbol
      stack.append(successor)
    else:
      circuit.append(stack.pop())  # emit vertices in reverse on backtrack

  circuit.reverse()

  # circuit is v0, v1, ..., vE with v0 == vE (closed). The new symbol of edge
  # vi-1 -> vi is the last character of vi, so the sequence is the trailing
  # symbol of every vertex after the start — exactly E symbols.
  return [vertex[-1] for vertex in circuit[1:]]

Takeaways

  • An Eulerian path uses every edge exactly once; an Eulerian circuit is a closed one. This is the easy cousin of the Hamiltonian tour (every vertex once), which is NP-complete — edges are easy, vertices are hard.
  • Undirected existence: a connected graph has an Eulerian circuit iff every vertex has even degree, and an Eulerian path iff exactly or vertices have odd degree (the two odds are the endpoints). The parity argument: each through-visit consumes a pair of incident edges.
  • Directed existence: an Eulerian circuit iff everywhere; an Eulerian path iff one vertex has (start), one has (end), rest balanced — plus connectivity of the edge set.
  • Hierholzer's algorithm builds a tour in by walking until it closes a sub-tour, then splicing in further closed sub-tours at vertices with leftover edges; a stack plus a per-vertex edge pointer emits the route in reverse on backtrack.
  • Path from circuit: join the two odd (or imbalanced) vertices by a virtual edge, build the circuit, cut it at that edge. Pitfalls: never delete edges by scanning (use pointers plus a shared bit), store edge ids so parallel edges survive, and check connectivity over nonzero-degree vertices only.
  • Applications: itinerary reconstruction (lexicographic via sorted edges), de Bruijn sequences (Eulerian circuit on the de Bruijn graph), and DNA fragment assembly.

Footnotes

  1. Erickson, Ch. — Graph Traversal: Eulerian tours via the degree characterization, contrasted with the NP-complete Hamiltonian problem.
  2. Skiena, § — Eulerian Cycles: the even-degree (balanced) existence condition and its parity proof.
  3. CLRS, Ch. — Euler Tour (Problem): linear-time construction of an Eulerian tour on a graph satisfying the degree/balance conditions.
Practice

╌╌ END ╌╌