Tower of Hanoi

1. Problem statement

Compute the sequence of steps, which each step as a 2-letter string in the format <disc_id><from><to>, that solve a given Tower of Hanoi problem of \(N\) discs on 3 rods (Aziz et al., 2018, p. 233).

2. Insights

2.1. Background

The goal is to step the tower of discs from rod 1 to rod 2 (it actually doesn't matter whether rod 2 or 3 is the final rod, because of symmetry), in the same top-down order (smallest disc on top, largest disc on the bottom), with the fewest number of steps. A larger disc may not be placed on top of a smaller one.

2.2. Recurrence

Note the following:

  • If there are no discs, 0 steps can be made.
  • If there is 1 disc, only 1 step is needed.
  • If there are 2 discs, 3 steps are needed.
  • If there are 3 discs, 7 steps are needed.
  • For \(n\) discs, \(2^{n-1}\) steps are needed.

Why is this so? Basically, the disc at the bottom (largest disc) needs only 1 step because it must (by definition) only go onto a rod with no discs on it (because all other discs are smaller than this one). And all previous discs must be stepd before the largest disc can be stepd.

Let's label the rods A, B, C. For the case of 2 discs (let's call them 1 and 2), it plays out (top to bottom) like this:

1
2
-   -   -    Step 0
A   B   C

--------------------------------------------------------



2       1
-   -   -    Step 1 = AC (Move subtower (disc 1) to other rod)
A   B   C

--------------------------------------------------------



    2   1
-   -   -    Step 2 = AB (Move largest disc into place)
A   B   C

--------------------------------------------------------


    1
    2
-   -   -    Step 3 = CB (Move subtower (disc 1) to final rod)
A   B   C

Here's the visual record of 3 discs:

1
2
3
-   -   -     Step 0
A   B   C

--------------------------------------------------------


2
3   1
-   -   -     Step 1 = AB (Move subtower 1+2 to other rod)
A   B   C

--------------------------------------------------------



3   1   2
-   -   -     Step 2 = AC (Move subtower 1+2 to other rod)
A   B   C

--------------------------------------------------------


        1
3       2
-   -   -     Step 3 = BC (Move subtower 1+2 to other rod)
A   B   C

--------------------------------------------------------


        1
    3   2
-   -   -     Step 4 = AB  (Move largest disc into place)
A   B   C

--------------------------------------------------------



1   3   2
-   -   -     Step 5 = CA (Move subtower 1+2 to final rod)
A   B   C

--------------------------------------------------------


    2
1   3
-   -   -     Step 6 = CB (Move subtower 1+2 to final rod)
A   B   C

--------------------------------------------------------

    1
    2
    3
-   -   -     Step 7 = AB (Move subtower 1+2 to final rod)
A   B   C

Notice that the first 3 steps for the case of 3 discs are the same 3 steps needed for the entire solution of the case of 2 discs. So the solution for 3 discs is really just: "step tower of 2 discs to another rod" + "step the largest disc to its final position" + "step tower of 2 discs to the final rod", or

\begin{align*} S(n) &= S(n-1) + 1 + S(n-1) \\ &= 2*S(n-1) + 1 \end{align*}

where \(S(n)\) means the number of steps needed. If we throw in the base case of \(S(0) = 0\), we arrive at the following recurrence:

\begin{equation} S_n = \begin{cases} 0 & \text{if } n = 0 \\ 2 * (S_{n - 1}) + 1 & \text{if } n > 0. \end{cases} \end{equation}

In summary, if we want to move \(n\) discs, we must move the tower made up of \(n-1\) discs over to another rod, then again to the final rod. These smaller \(n-1\) discs are free to move (use any of the rods) as they are all smaller than the largest disc.

3. Solution

3.1. Recursive

The key here is to encode the "move subtower to other rod", "move largest disc" and "move subtower to final rod" actions into a recursive function. The "move largest disc" is our base case where the recursion ends.

def recursive(discs_to_move: int):
    result = []

    def record_steps(discs: int,
                     rod_start: str,
                     rod_final: str,
                     rod_other: str):

        if not discs:
            return

        # Move subtower from the starting rod to the other rod. "First leg" of
        # recursion.
        record_steps(discs - 1, rod_start, rod_other, rod_final)

        # Move largest disc to final rod, and record this step. We reuse "discs"
        # as the disc "id". "Largest" here depends on context.
        result.append(str(discs) + rod_start + rod_final)

        # Move subtower from the other rod to the final rod. "Second leg" of
        # recursion.
        record_steps(discs - 1, rod_other, rod_final, rod_start)

    # Record the steps!
    record_steps(discs_to_move, 'A', 'B', 'C')

    return result

Another way to look at this is that we have a binary tree of recursive calls, where we can trace the execution of the calls by doing an in-order traversal.

recursive_call_tree.svg

Figure 1: Recursive call tree of function arguments for record_steps().

We can use a binary tree to model the trace, because we recurse twice, in the first and second legs. And our model uses in-order traversal because our code to do actual work (the "moving" of the disc, which we record into the result array), is done between the two recursive calls, just like how in-order traversal visits after the left subtree is visited first (but before right subtree).

3.2. Iterative

Here we simulate the recursive approach by emulating a function call stack. We very precisely trace the execution of the recursive function, but do it in iterative fashion by simulating the call stack with an explicit stack. When we need to recurse, we push into the stack a "RecursiveCall".

def iterative(discs_to_move: int):

    result = []

    def record_steps(discs: int,
                     rod_start: str,
                     rod_final: str,
                     rod_other: str):

        class RecursiveCall(NamedTuple):
            discs: int
            rod_start: str
            rod_final: str
            rod_other: str

        callstack: List[RecursiveCall] = []

        while discs or callstack:

            # Simulate a recursive call. Initially, this loop simulates the
            # "first leg" of recursion. Later, it will simulate the second leg
            # as well.
            while discs:
                rc = RecursiveCall(
                    discs,
                    rod_start,
                    rod_final,
                    rod_other,
                )
                callstack.append(rc)
                rod_final, rod_other = rod_other, rod_final
                discs -= 1

            # "Execute" the function call at the top of the stack. We don't
            # really care how the function call got to the top --- we just
            # execute it to make our "CPU" make progress.
            exec_me = callstack.pop()
            result.append(str(exec_me.discs) +
                          exec_me.rod_start +
                          exec_me.rod_final)

            # The combination of this stanza and the above while loop just above
            # will simulate the second leg of recursion. Recurse
            if exec_me.discs > 0:
                discs = exec_me.discs - 1
                rod_start = exec_me.rod_other
                rod_final = exec_me.rod_final
                rod_other = exec_me.rod_start

    record_steps(discs_to_move, 'A', 'B', 'C')

    return result

Consider the example where we call iterative(3). The first thing we do is simulate the recursion down to the leftmost node in the call tree (as illustrated earlier in Figure 1). The call stack at this point looks like this:

1,A,B,C
2,A,C,B
3,A,B,C

Then we pop the stack to process 1,A,B,C and execute it.

2,A,C,B
3,A,B,C

Now we are in the next iteration for the outer while loop. We pop the stack to process 2,A,C,B and execute it. Our stack looks like

3,A,B,C

Now we are in the next iteration for the outer while loop again. But now we execute the inner while loop because our last execution set discs to \(2 - 1 = 1\), and so we create the next recursive call, 1,B,C,A and add it to the stack. Now our stack looks like

1,B,C,A
3,A,B,C

and we pop the stack and execute it. And so on and so forth.

Overall it's a bit tricky, but the key ideas are:

  • simulate a function call with an explicit stack,
  • process the stack once in the outer loop's iteration to make progress,
  • have another nested loop to build up the stack again to simulate further recursive calls, and
  • modify the "base case detector" variable, in our case the discs variable, as needed to control the growth of recursion.

4. Tests

🔗
from typing import List, NamedTuple
import unittest

solution

class Test(unittest.TestCase):
    test_cases

if __name__ == "__main__":
    unittest.main(exit=False)

4.1. Basic tests

def test_recursive(self):
    self.assertEqual(recursive(0), [])
    self.assertEqual(recursive(1), ["1AB"])
    self.assertEqual(recursive(2), ["1AC", "2AB", "1CB"])
    self.assertEqual(recursive(3), ["1AB", "2AC", "1BC",
                                    "3AB",
                                    "1CA", "2CB", "1AB"])
def test_iterative(self):
    self.assertEqual(iterative(0), [])
    self.assertEqual(iterative(1), ["1AB"])
    self.assertEqual(iterative(2), ["1AC", "2AB", "1CB"])
    self.assertEqual(iterative(3), ["1AB", "2AC", "1BC",
                                    "3AB",
                                    "1CA", "2CB", "1AB"])

# Do the recursive and iterative solutions agree with each other?
def test_cross_check(self):
    for i in range(8):
        self.assertEqual(recursive(i), iterative(i))

5. References

Aziz, A., Lee, T.-H., & Prakash, A. (2018). Elements of Programming Interviews in Python: The Insiders’ Guide. CreateSpace Independent Publishing Platform (25 July. 2018).