23: (No) More Recursion!
Announcements
SRTIs open! Please take the time to give me (and the department) meaningful feedback about the course content and my teaching. I really do read them all and work to improve on the basis of your comments.
Even More Recursion
Today we’re going to spend some time doing on problems that (a) have recursive solutions and that (b) are not obviously amenable to non-recursive solutions.
Binary trees
Remember earlier in the semester when we talked about binary trees? They’re like lists: structures composed of linked nodes. But in a binary tree, each node has a left and right “child” rather than a single next node:
class Node<E> {
public E data;
public Node<E> left;
public Node<E> right;
}
Some types of binary trees have additional constraints. For example, binary search trees might require that the values stored in the left subtree always be less than or equal to the current node’s value, and the values stored in the right subtree always be greater.
As you’ll see in 187, you can operate on these trees either recursively or iteratively. Some operations are about as hard to write iteratively or recursively (not unlike writing contains
in a linked list). Others are more clear one way or the other. Let’s do a few examples. The basic tree looks like this:
class BinarySearchTree<E> { // note the E extends Comparable<E> is necessary
// for the ordering to be enforced
private Node<E> root;
}
Suppose we have an existing tree. How do we find an element in the tree? Let’s write a contains()
method both ways. This bears a strong resemblance to the contains
method of a linked list, but has to check the correct child of a node (notably, not both children!).
(1) We start at the root. then, we (2) repeat the following: If the node contains the data, (3) return true; otherwise, (4) descend to the appropriate child. If we reach the end without finding the value, (5) return false.
public boolean contains(E e) {
// 1
Node<E> node = root;
// 2
while (node != null) {
// 3
if (e.equals(node.data)) {
return true;
}
// 4
if (e.compare(node.data) <= 0) {
node = node.left;
} else {
node = node.right;
}
}
// 5
return false;
}
What about recursively? We’ll need a helper method to be able to pass the “current” node in, but otherwise it’s much the same.
Our textual description is something like:
In-class exercise
What is the true
base case in contains
?
In-class exercise
What is the false
base case in contains
?
contains is false if we’ve reached the end of a branch. It’s true if the current node’s value is the value we’re looking for. Otherwise, it’s equal to contains(left child) if the value is less than the current node, or contains(right child) otherwise.
public boolean contains(E e) {
return contains(root, e)
}
private boolean contains(Node<E> node, E e) {
if (node == null) {
return false;
}
if (e.equals(node.data)) {
return true;
}
if (e.compare(node.data) <= 0) {
return contains(left, e);
}
else {
return contains(right, e);
}
}
How do we insert data? Just like a list, we need to “traverse” the tree to the “end”, but unlike a list, we can’t just follow the next
reference; instead, we need to find the right place in the tree. What’s the right place?
(1) We start at the root. If it’s null
, the tree is empty and we can put the value in a new node there.
If not, we need to decide to go left or right. How do we decide? (2) If the value we’re inserting is less than or equal to the current node’s value, we should go left. Otherwise, right. (3) Then keep going, until we get to a null value? Not all the way to a null, otherwise we don’t know what node to “staple” our new node too. Just like a linked list, we want to stop before we go “past” the end, then (4) add the node to the correct side:
public void insert(E e) {
Node<E> node = new Node<>();
node.data = e;
// 1
if (root == null) {
root = node;
return;
}
// 2
Node<E> cur = root;
Node<E> next;
if (e.compare(cur.data) <= 0 ) {
next = cur.left;
}
else {
next = cur.right;
}
// 3
while (next != null) {
cur = next;
if (e.compare(cur.data) <= 0 ) {
next = cur.left;
}
else {
next = cur.right;
}
}
// 4
if (e.compare(cur.data) <= 0 ) {
cur.left = node;
}
else {
cur.right = node;;
}
}
Note you could shorten this somewhat with the dreaded ternary operator. Instead of:
if (e.compare(cur.data) <= 0 ) {
next = cur.left;
}
else {
next = cur.right;
}
you could write:
next = (e.compare(cur.data)) <= 0 ? cur.left : cur.right;
What about the recursive version? Again, we need to find the node before the place we want to insert the the node. So we’ll need a helper method, which we’ll use to find the node to insert at:
public void insert(E e) {
Node<E> node = new Node<>();
node.data = e;
// 1
if (root == null) {
root = node;
return;
}
Node<E> cur = findInsertionPoint(root, e);
if (e.compare(cur.data) <= 0 ) {
cur.left = node;
}
else {
cur.right = node;;
}
}
public Node<E> findInsertionPoint(Node<E> node, E e) {
Node<E> next;
if (e.compare(node.data)) <= 0) { // again, could use ternary op instead
next = node.left;
}
else {
next = node.right;
}
if (next == null) {
return node;
}
else {
return findInsertionPoint(next, e);
}
}
Is one more clear than the other? I dunno.
Two more quick binary tree examples, this time of methods that recursion makes easier. Suppose your binary tree contains Integer
s. How would you find the sum of the values? If this were a list, you would traverse the list and sum them up as you went. But if you “iterate” through a tree, you must go either left or right. What about if you recurse? Then you can define sum() as follows:
The sum of the values of the tree rooted at a given node is equal to:
- zero, if the node is null
- otherwise, it’s equal to the current node’s value, plus sum(left child) + sum(right child).
(on board)
That’s a clear recursion, the code would look something like:
int sum(Node<Integer> node) {
if (node == null) {
return 0;
}
else {
return node.data + sum(node.left) + sum(node.right);
}
}
Suppose we wanted to just count the number of nodes in the tree?
In-class exercise
What is the base case in count
?
int count(Node<Integer> node) {
if (node == null) {
return 0;
}
else {
return 1 + sum(node.left) + sum(node.right);
}
}
Here’s another example. What if we want to calculate the height of a tree? Remember, the height is the distance from the root to the node furthest from the root (on board).
Again, we don’t know in advance which child of a given node leads toward the “deeper” subtree, so we have to explore both. We can define the height of the subtree rooted at a node as:
- zero, if the current node has no children
- one plus the larger of the heights of the left and right subtrees
Again, in Java, though this one’s slightly messier due to the null check:
int height(Node<E> n) {
if (n.left == 0 && n.right == 0) {
return 0;
}
if (n.left == 0) {
return 1 + height(n.right);
}
if (n.right == 0) {
return 1 + height(n.left);
}
return 1 + Math.max(height(n.left) + height(n.right));
}
Can you write these (sum
, count
, and height
) iteratively? Sure, but not without (at least) one of two things:
- You could modify the tree to include
parent
references, which would allow backtracking. - Or, you could use auxiliary storage to keep track of the “pending” parts of the tree. This approach exactly parallels how search works: you need to keep track of the “frontier” of nodes, removing one at a time from it, and adding all children of a current node to it. (You don’t need to track the visited set, since there are by definition no loops in a tree.)
Recursion is equivalent to the latter – the program’s call stack is your auxiliary storage, and if you use a stack to track the frontier, your iterative program can be written to visit the nodes in exactly the same order as the recursive version.
Hanoi
Tower of Hanoi, a simple puzzle where you move disks from one peg to another, the goal being to move all the disks from the first peg to the last. The constraints are that you may only move one disk at a time, and you must place a disk on either an empty peg, or atop a disk larger than itself.
A recursive procedure to solve the puzzle is as follows. Label the three pegs the source (that is, the starting) peg, the target (the place we want the disks all to go), and the spare (the other peg). To move m
disks from the source to the target, use the following solve(m, source, target, spare)
procedure:
- If we are being asked to move zero pegs, do nothing (base case).
- Move m − 1 disks from the source to the spare peg using
solve(m - 1, source, spare, target)
This leaves the disk m as a top disk on the source peg and all the other disks on the spare peg. - Move the disk m from the source to the target (which is empty).
- Move the m - 1 disks from the spare to the target, using the now-empty source peg as the spare:
solve(m - 1, spare, target, source)
What does this look like in code? Let’s declare a Hanoi class to handle this:
public class Hanoi {
int n;
private List<Character> a;
private List<Character> b;
private List<Character> c;
public Hanoi(int n) {
this.n = n;
a = new ArrayList<>();
b = new ArrayList<>();
c = new ArrayList<>();
for (char i = (char)('1' + n - 1); i >= '1'; i--) {
a.add(i);
}
}
public String toString() {
return a.toString() + "\n" + b.toString() + "\n" + c.toString();
}
public static void main(String[] args) {
int n = 3;
Hanoi h = new Hanoi(n);
System.out.println(h);
}
And translate our recursive solver into code, including printouts each time it moves a disk:
private void solve(int m, List<Character> source, List<Character> target, List<Character> spare) {
if (m == 0) {
return;
}
solve(m - 1, source, spare, target);
target.add(source.remove(source.size() - 1));
System.out.println("----");
System.out.println(this);
solve(m - 1, spare, target, source);
}
And finally, set up to call it:
public void solve() {
solve(n, a, c, b);
}
Et voilà:
public static void main(String[] args) {
int n = 3;
Hanoi h = new Hanoi(n);
System.out.println(h);
h.solve();
}
How does it work? It works well. 😎
You’ll get the tools you need to prove it’s correct in COMPSCI 250.