Hamiltonian paths tutorial

Introduction

Hamiltonian paths are rarely encountered in video games compared to "shortest path" algorithms like A*. This is partly because the Hamiltonian paths problem is notoriously difficult, but mostly because there haven't been enough open source projects, libraries or tutorials on the subject. This is a real shame since many genres of games could be designed around the use of Hamiltonian paths.

Diagram 1: Super Chains, a puzzle game developed by 2dengine.
Find the longest chain of bubbles sharing the same color.


Hamiltonian paths present a search problem that is considered "NP-hard". Some really sophisticated research has gone into this problem, so please consider this humble tutorial as a brief introduction.

Knight's tour problem

Imagine a regular 8x8 chessboard. Can you traverse every square on the board using only the knight piece? Here is the catch: you are only allowed to visit each square once! It takes exactly 63 moves to solve the puzzle and if you manage to do it, then you have found one possible "Hamiltonian path". Note that if you can finish the Knight's tour one move away from the initial square where you started then you have completed what is called a "Hamiltonian cycle".

Diagram 2: Knight piece with eight possible moves highlighted in yellow.

Building the graph

Initially, it seems like Hamiltonian paths are an easy challenge for the computer. All we have to do is track the number of visited squares, right? Before we give it a try, let's refer to the chessboard as a "graph" and its squares as "vertices" or "nodes". Any possible move of the knight will be called an "edge". Why? Because technical nomenclature make us sound smart! When we draw all possible connections on the board then the terminology makes a little more sense:

Diagram 3: Graph of the legal knight moves on a chessboard:
Lines are called "edges", where each edge connects two neighboring nodes
Circles are called "nodes" and each number represents the number of edges for that node

The graph is represented in Lua as a table where: 1.each key is a node and 2.each value is a table containing the neighboring nodes.
This simple approach allows us to check if any two nodes are connected by writing:

if graph[node1][node2] then -- node1 and node2 are connected
Next, we are going to build a graph representing the chessboard. Each node or square on the board will be labeled with a number (from 1 to 64 for an 8x8 board). To connect the nodes, we need a way to find all the legal knight moves for any given square. The following example uses a method called "mailboxing". Mailboxing adds a few rows of padding and allows us to eliminate invalid moves, so that our poor knight can't jump off the board! If you have any chess knowledge at all, you could probably come up with a different approach of generating the graph.

-- mailbox
local w, h = 8, 8
local squares = {}
local sq = 1
for y = 3, h + 2 do
  local i = (y - 1)*(w + 2)
  for x = 2, w + 1 do
    squares[i + x] = sq
    sq = sq + 1
  end
end

-- graph
local knight = { -21, -19, -12, -8, 8, 12, 19, 21 }
local graph = {}
for from, sq in pairs(squares) do
  graph[sq] = {}
  for _, move in pairs(knight) do
    local to = from + move
    local sq2 = squares[to]
    if sq2 then
      graph[sq][sq2] = true
    end
  end
end

Traversing the graph

We know for a fact that the Knight's tour problem can be solved with 63 moves. If we include the starting square that's a path of 64 squares.
Generally, we can figure out the longest, theoretically possible Hamiltonian path for any graph using the formula:
maxdepth = numberOfNodes - max(nodesWithOnlyOneEdge - 2, 0)
This value is important, because it serves as an upper bound while searching for a solution.
To estimate the length of the longest Hamiltonian path, we use the following code:
-- count the number of edges for a node
function count(edges)
  local n = 0
  for neighbor in pairs(edges) do
    n = n + 1
  end
  return n
end

-- estimate the longest Hamiltonian path
function maxdepth(graph)
  local n = 0
  local edge1 = 0
  for node, edges in pairs(graph) do
    if count(edges) == 1 then
      edge1 = edge1 + 1
    end
    n = n + 1
  end
  return n - math.max(edge1 - 2, 0)
end

Brute force

OK, though guy... you recently upgraded your CPU and think you can plow through the Hamiltonian path problem in no time. The bad news is that that there are estimated 4x1051 possible move sequences and the valid solutions are quite rare. Now that you have an idea of the enormous size of the search space, it should be clear that using "brute force" is clearly not an option.

Warnsdorf's rule

While there are linear-time algorithms for solving the Knight's tour, this is not the case for more complicated types of graphs. Nevertheless, mathematicians have discovered a few clever tricks. Warnsdorf's rule suggest that when searching the graph, we should seek the nodes that have the fewest non-visited neighbors. Let's look at a concrete example.

Diagram 4: Graph traversal following Warnsdorf's rule
1. Graph of four nodes (A,B,C and D) and five edges, traversal begins from node A.
2. Node A: There are three possible choices (B,C and D). Two of the choices (B and C) have fewer non-visited neighbors compared to the third choice (D). According to Warnsdorf, choices B and C must be considered before D.
3. Node C: There is one possible choice (D)
4. Node D: There is one possible choice (B)

Warnsdorf's rule is good for simple graphs like our chessboard, but it may not always work. In many cases, following the rule is not enough to find a solution. Therefore, our code needs to perform some backtracking.

local visited = {}

-- count the number of non-visited neighbors
function nonvisited(edges)
  local n = 0
  for neighbor in pairs(edges) do
    if not visited[neighbor] then
      n = n + 1
    end
  end
  return n
end

-- traverse the graph using Warnsdorf's rule
function traverse(graph, node, depth, best, path)
  depth = depth or maxdepth(graph)
  path = path or {}
  best = best or {}
  -- track the longest path
  table.insert(path, node)
  if #path > #best then
    for i = 1, #path do
      best[i] = path[i]
    end
  end
  -- visit neighboring nodes
  if #best < depth then
    visited[node] = true
    -- consider all non-visited neighbors
    local choices = {}
    for neighbor in pairs(graph[node]) do
      if not visited[neighbor] then
        table.insert(choices, neighbor)
      end
    end
    -- sort choices by fewest non-visited neighbors
    table.sort(choices, function(a, b)
      return nonvisited(graph[a]) < nonvisited(graph[b])
    end)
    -- try each available choice
    for i = 1, #choices do
      if traverse(graph, choices[i], depth, best, path) then
        break
      end
    end
    visited[node] = nil
  end
  table.remove(path)
  return #best == depth, best
end

Choosing a starting node

Note that with the Knight's tour problem we can start the search from any square, but for other types of graphs some starting nodes could be eliminated.
For the purposes of this tutorial, we want to keep it simple. Using the "traverse" algorithm is as easy as providing a graph and a starting node:
-- search for Hamiltonian paths
local res, path = traverse(graph, 1)
-- print the longest path found
error("found path:" .. table.concat(path,','))

I would like to end this tutorial by saying that the code provided here is far from perfect. The methods we have considered are just slightly more advanced than using brute force, but the difference in speed is significant. The tutorial code could probably handle most small graphs in less than a second, but imagine what may be possible with more sophisticated techniques! More importantly, I hope to have encouraged you to learn about graph theory. Thanks for reading and have a nice day.