Lecture 19: Balancing Act

Our research into Trees continues…

Trees and Recursion

Our implementation of Binary Search Trees from last time was what we call an iterative solution to the problem, but when it comes to Trees it is often better to devise recursive solutions.

A little review of recursion since it may have been some time since you’ve seen it:

A recursive algorithm is an algorithm that calls itself at some point in its execution.

thank you freeCodeCamp for the graphic

Designing a recursive function is all about finding the right structure - we begin by asking ourselves two questions:

  1. What is the Base Case of my function?
  2. What is the Recursive Case of my function?

I’ll answer these backwards:

The Recursive Case of your function is the condition under which you want your function to call itself again (this time usually with a new argument). However, we need to know what condition to stop and not call ourselves again, which leads to…

The Base Case of your function, the condition under which, based on the argument or some other condition, we can be certain about value we want to return - this terminates our recursion and begins the process of returning values back up to the original function call.

When writing recursive code, focus on one level at a time. Ask: "If my recursive call magically works correctly on the smaller input, does my code combine that result correctly?" Don't try to trace the whole call stack in your head.

Trees are inherently recursive structures: every node is the root of its own subtree. This means the solution to a tree problem often looks like “do something with this node, then solve the same problem on the children,” but can also look like “solve the problem on this node’s children, then on this node,” which often means the first Node the problem is actually solved on is one of the tree’s leaf nodes!

Let’s give a shot at implementing recursive solutions for the functionalities of our Binary Search Tree that we’ve already written iteratively.

Before we see how to do so, let’s take a look at a small recursive tree method on our basic BinaryTreeNode class (i.e., the non-search tree version from last lecture).

Guideline 1: Remember that if the signature of the method you're implementing is not what you want, you can always create a private helper with whatever parameters you please!

Example

Implement the getLeaves method in BinaryTreeNode, which returns a List of the values held in the leaves of the tree.

public ArrayList<Integer> getLeaves () {
    ArrayList<Integer> result = new ArrayList<>();
    getLeaves(this, result);
    return result;
}    
 
/**
* Recursively performs a preorder traversal on the given Binary Tree to create a
* List of all leaf values stored within. 
*/
private void getLeaves(BinaryTreeNode n, ArrayList<Integer> result) {
    // Base case:
    if (n == null) { return; }
    // Visit behavior: check to see if we're at a leaf, and add it if so
    if (n.left == null && n.right == null) {
        result.add(n.data);
        
    // Recursive case: not at a leaf, so keep looking deeper
    } else {
        getLeaves(n.left, result);
        getLeaves(n.right, result);
    }
}

Notes on the above:

  • Note how the original, public method of getLeaves wasn’t in a format that we wanted, so instead, we created an intuitive helper method in the format that was convenient!
  • Remember that ArrayLists are objects, and since objects are passed by reference, we can manipulate the same ArrayList declared in the public getLeaves method as in the private helper.

Guideline 2: Remember that recursive methods are still method calls that can return some value to the recursive call that called them! We can exploit this to pass back values to previous frames on the call stack.

Example

Suppose instead we wanted to implement the BST’s addition recursively, inspired by the above, we could do the following:

public void addRec (int toAdd) {
    // Set the root equal to the result of the helper
    this.root = this.addRec(this.root, toAdd);
}
 
private BSTNode addRec (BSTNode n, int toAdd) {
    // ...which will either be a new node in the case of an empty tree,
    // or where we've found the insertion spot to be
    if (n == null) {
        return new BSTNode(toAdd);
    }
    // ...and can set the left and right references appropriately depending
    // on where the node should go
    if (toAdd < n.data) {
        // [!] Update balance factor here since it'd go in left subtree
        n.left = addRec(n.left, toAdd);
    } else {
        // [!] Update balance factor here since it'd go in right subtree
        n.right = addRec(n.right, toAdd);
    }
    // Finally, returning n as the current occupant of the parent's reference
    return n;
}

Example

Implementing the BST’s binary search is a somewhat similar process:

public boolean containsRec(int query) {
    // Call a recursive helper to decide if the
    // query is in our tree and return its result
    return containsRec(this.root, query);
}
 
private boolean containsRec(BSTNode current, int query) {
    // If we get passed a null BSTNode, it means 
    // the query would have been in this subtree
    // if it existed, which means it doesn't exist!
    if (current == null) {
        return false;
    }
    // If we find the query, we can return true
    if (current.data == query) {
        return true;
    }
    // Otherwise, check if the current node is
    // less or greater than our query, and recurse
    // left or right depending on the answer to
    // that question
    if (query < current.data) {
        return containsRec(current.left, query);
    }
    return containsRec(current.right, query);
}

Much like our iterative version, our logic moving left and right can be condensed into a ternary statement if we so choose, so our recursive helper could look something more like:

private boolean containsHelper(BSTNode current, int query) {
    if (current == null) return false;
    if (current.data == query) return true;
    return containsHelper(
        query < current.data ? current.left : current.right,
        query
    );
}
}

Let’s try implementing something we discussed in theory but didn’t implement last time - outputting the elements of a Binary Search Tree in sorted order!

Example

Add a method public ArrayList<Integer> getSortedList(); to the BST class above that returns a list of Integers composed of the BST’s ints in sorted order.

public ArrayList<Integer> getSortedList() {
    // Create an ArrayList that we'll return later
    ArrayList<Integer> result = new ArrayList<Integer>();
    // Pass in the ArrayList to a recursive private helper method,
    // along with the root.
    // [!] Remember that Objects pass a copy of their *reference* in
    // Java, so buildSortedList will modify the list it's passed in, 
    // but doesn't need to return it, as we're still holding onto a
    // reference to the same object in this method!
    buildSortedList(this.root, result);
    // Finally, return the list of sorted values.
    return result;
}
 
private void buildSortedList(BSTNode current, ArrayList<Integer> result) {
    // If we've been passed a null BSTNode, there's nothing
    // to do here, so we just return!
    if (current == null) {
        return;
    }
    // First, recurse on this Node's left subtree
    buildSortedList(current.left, result);
    // Once the left subtree's nodes have been added
    // to the list, append this Node to the list.
    result.add(current.data);
    // Finally, recurse on this Node's right subtree
    buildSortedList(current.right, result);
}

BST Applications

Sets are a popular application Abstract Data Type that are implemented using Binary Search Trees, where Sets are simply unordered-collections of items where duplicates are not allowed.

The Java Collections Framework Set implemented as a self-balancing Binary Search Tree is known as the TreeSet data structure.

The two most common operations on a Set are really just additions and set membership tests, just like we’ve implemented in our basic BST class above.

public static void main(String [] args) {
    TreeSet<Integer> leafy = new TreeSet<>();
    leafy.add(1);
    leafy.add(2);
    leafy.add(3);
    leafy.add(3);
    // [?] What gets printed below?
    System.out.println(leafy.size());
    System.out.println(leafy.contains(3));
    System.out.println(leafy.contains(4));
}

Warning: Using ArrayLists for membership tests is pretty inefficient and is one of the biggest novice moves that tanks efficiency!

Example

Consider the following timed unit tests in which we add a bunch of ints to both an ArrayList and a TreeSet and then perform membership tests on each.

public class TreeSetTests {
 
    private static int TEST_SIZE = 200000;
    
    @Test
    public void testArrayListMembership () {
        ArrayList<Integer> arr = new ArrayList<>();
        for (int i = 0; i < TEST_SIZE; i++) {
            // [!] Appending i to ArrayList
            arr.add(i);
        }
        for (int i = 0; i < TEST_SIZE; i++) {
            // [!] Membership tests
            arr.contains(i);
        }
    }
    
    @Test
    public void testTreeSetMembership() {
        TreeSet<Integer> ts = new TreeSet<>();
        for (int i = 0; i < TEST_SIZE; i++) {
            // [!] Adding i to BinarySearchTree
            ts.add(i);
        }
        for (int i = 0; i < TEST_SIZE; i++) {
            // [!] Membership tests
            ts.contains(i);
        }
    }
 
}

Whew, that’s night and day! Once again teaching us that certain tasks (like membership tests) are better suited for certain data structures than others!

Let’s connect the above to some of your last lectures.

Notice how, for even small n, the difference between those computational classes is massive in wallclock time!

This isn’t just about theory, it’s about the practical efficiency that can be really costly when we make the wrong choice… but you’ll know better having taken this class!

Tree Balancing

Last time, we noticed a bit of a problem with inserting values into our Tree…

But not all hope is lost… in come Tree Balancing!

Balancing binary search trees is an operation that prevents them from becoming too linear, thus securing the performance guarantees we expect in a BST.

There are a variety of different ways to keep trees balanced and maintain the log(n) search guarantee for binary search trees; we’ll examine a couple now.

AVL Trees

AVL trees have 1 property in addition to those of binary search trees: the heights (depths) of any two of a given node's subtrees must differ by at most 1.

This enforced property makes sure that the depth of any subtree never becomes too linear, which would degrade the efficiency of search and further insertions.

This balancing act takes time, which means that, upon insertion of any new value, we get time; a greater overhead, but same complexity class.

  1. Firstly, we keep track of a “balance factor” at each node which is equal to: balance = height(left_subtree) - height(right_subtree)
  2. A tree is out of balance if its balance factor is greater than or equal to 2, or less than or equal to -2.
  3. If a tree is out of balance, then we use the following balancing algorithm:
// Code skeleton for C++, edited from Wikipedia :)
if (balance_factor(L) == 2) { // The left subtree
  Node* P = left_child(L);
  if (balance_factor(P) == -1) { // The "Left Right Case"
      rotate_left(P); // Reduce to "Left Left Case"
  }
  // The Left Left Case
  rotate_right(L);
  
} else { // balance_factor(L) == -2, the right subtree
  Node* P=right_child(L);
  if (balance_factor(P) == 1) { //The "Right Left Case"
     rotate_right(P); // Reduce to "Right Right Case"
  }
  // The Right Right Case
  rotate_left(L);
}

So what does it mean to “rotate” a tree, you might ask?

A rotation promotes a child node to the parent, and denotes the parent to a child node depending on the direction of the rotation. Subtree structures are maintained.

Above, a right / clockwise rotation would start at the right image and finish with the left image.

Similarly, a left / counter-clockwise rotation would start at the left image and finish with the right image.

Notice that the subtree structures are maintained with each rotation.

Now that we have the basics of rotation down, let’s look at an example inserting into an AVL tree:

Neat!

So remember: AVL trees balance subtree heights, not necessarily just the number of nodes per subtree!

Let's examine a visualization here (click me)

Here’s another site that gives you more control over the animation: click me!

Red-Black Trees

So, the one problem with AVL trees is that with a lot of insertions, you’ll have to keep performing the balancing overhead any time a branch becomes 2 depths or more greater than a parent.

Red-Black Trees said, “Hey, let’s not do all of this rebalancing nonsense all the time; everything’s chill until the path from the root to the farthest leaf is no more than twice the distance from the root to the closest leaf.”

So, without diving deep into how it rebalances when this distance is exceeded, let’s just look at some properties of red-black trees lifted from wikipedia:

  • A node is “painted” either red or black (a boolean flag)
  • The root is black
  • Every red node must have two black child nodes
  • Every path from a given node to any of its descendant leaves contains the same number of black nodes.

What we end up with is a binary search tree that still ensures O(log(n)) search without the need to so frequently rebalance.

Let's examine a visualization here (click me)

Summary


 You do not need to know all of the specifics of these tree balancing algorithms for this course, though the following are important ideas:

  • Tree balancing is important to prevent binary search trees from becoming too linear and thus losing their O(log(n))
  • performance for insertion / removal.
  • Tree balancing algorithms exist in a number of different formats, but all of which deliver a balanced tree upon addition in cost that’s at most O(log(n))
  • One such method is through the AVL tree’s specification of a balance factor at every node that is updated upon a recursive addition of Nodes to the tree, and serves as a trigger for rebalancing whenever subtree depths differ by more than 1.