SEN2212 · Week 4 · Implementing binary search tree using Java.

Writing a BST in Java

A generic BinarySearchTree class: the node class, recursive insert/remove/contains/findMin/findMax, traversals, size and height; the 'write a method' patterns asked in exams.

generic classComparablecompareTonested classBinaryNoderecursive helperpublic driver methodinsertremovecontainsfindMinfindMaxsizeheightrange countkth smallestfloorlowest common ancestor

Last week you worked through the BST on paper. This week we put the same algorithms into Java. The exam has two kinds of questions: the output of given code (traversals after insert/remove calls) and “write a method”: short recursive methods such as size, height, count of elements in a range, k-th smallest, BST check. Someone who knows the patterns on this page can write a new method in 5 minutes too.

Three golden rules:

  1. A recursive method returns a node, and the caller assigns it: t.left = insert(x, t.left);. Java passes references by value.
  2. Every method deals with the t == null case first. If the base case is wrong, everything else collapses.
  3. If you can prune one side of a BST, prune it. Operations like search, range and floor are O(h); operations that traverse the whole tree are O(n).

The skeleton of the class

The key type is generic. Comparison is done with compareTo, not <, so the type must be Comparable.

public class BinarySearchTree<T extends Comparable<? super T>> {

    private static class BinaryNode<T> {
        T element;
        BinaryNode<T> left, right;
        BinaryNode(T e) { element = e; }
    }

    private BinaryNode<T> root;   // null in an empty tree

    public boolean isEmpty() { return root == null; }
    public void makeEmpty()  { root = null; }
}
  • BinaryNode is a private static nested class: it is invisible from outside and is not tied to a tree object.
  • <? super T>: it means accept it even if not T itself but a superclass of T is Comparable. Just memorize it.
  • makeEmpty only sets the root to null. The garbage collector cleans up the nodes.

Public driver, private helper

Each operation consists of two methods. The public one is open to the user and passes only the root. The private one does the real recursion and takes the root of a subtree as a parameter.

public void insert(T x)      { root = insert(x, root); }
public void remove(T x)      { root = remove(x, root); }
public boolean contains(T x) { return contains(x, root); }

The user never sees the concept of a node. In operations where the root can change (the first insertion, deleting the root), the root = ... assignment is required.

contains, findMin, findMax

private boolean contains(T x, BinaryNode<T> t) {
    if (t == null) return false;
    int c = x.compareTo(t.element);
    if (c < 0) return contains(x, t.left);
    if (c > 0) return contains(x, t.right);
    return true;                         // c == 0: found
}

private BinaryNode<T> findMin(BinaryNode<T> t) {
    if (t.left == null) return t;        // recursive
    return findMin(t.left);
}

private BinaryNode<T> findMax(BinaryNode<T> t) {
    while (t.right != null) t = t.right; // with a loop
    return t;
}

compareTo returns negative, zero or positive. Do not check with == -1: the contract guarantees only the sign. Since contains is tail recursion, it is easy to turn into a loop. The private versions of findMin and findMax return a node; the public versions throw an exception on an empty tree and return element.

insert

private BinaryNode<T> insert(T x, BinaryNode<T> t) {
    if (t == null) return new BinaryNode<>(x);   // new leaf
    int c = x.compareTo(t.element);
    if (c < 0)      t.left  = insert(x, t.left);
    else if (c > 0) t.right = insert(x, t.right);
    // c == 0: duplicate, do nothing
    return t;
}

The logic step by step: the call insert(25, root) goes from 40 to 20, and from 20 to 30. The left of 30 is null, insert(25, null) returns a new node, and the assignment 30.left = new node is made. On the way back up, the other assignments (20.right = 30, 40.left = 20) rewrite links that already exist, which breaks nothing.

PITFALL · recursion without assignment. If you write insert(x, t.left); and do not assign the returned value, the new node is left dangling. Doing t = new BinaryNode<>(x) inside the method does not change the caller’s reference. Result: the tree has only the root.

remove

private BinaryNode<T> remove(T x, BinaryNode<T> t) {
    if (t == null) return null;                  // absent: do nothing
    int c = x.compareTo(t.element);
    if (c < 0)      t.left  = remove(x, t.left);
    else if (c > 0) t.right = remove(x, t.right);
    else if (t.left != null && t.right != null) {        // two children
        t.element = findMin(t.right).element;
        t.right = remove(t.element, t.right);
    } else {                                     // 0 or 1 child
        t = (t.left != null) ? t.left : t.right;
    }
    return t;
}

The ternary expression in the last line handles two cases at once. At a leaf both children are null, so t becomes null and the parent’s reference is set to null. With one child, t becomes that child and the parent attaches directly to it.

In the two-children case a second search is done in the right subtree. The total is still O(h)O(h): once down to find the successor, and once to delete it.

Traversals, size, height

private void inorder(BinaryNode<T> t, StringBuilder sb) {
    if (t == null) return;
    inorder(t.left, sb);
    sb.append(t.element).append(' ');
    inorder(t.right, sb);
}

private int size(BinaryNode<T> t) {
    return t == null ? 0 : 1 + size(t.left) + size(t.right);
}

private int height(BinaryNode<T> t) {
    return t == null ? -1 : 1 + Math.max(height(t.left), height(t.right));
}

size is O(n)O(n) because it traverses the whole tree on every call. If it is called often, adding an int size field to the class and updating it in insert and remove gives O(1)O(1). But then you need to know whether insert really added something (or was a duplicate).

A worked example

BinarySearchTree<Integer> bst = new BinarySearchTree<>();
int[] keys = {40, 20, 60, 10, 30, 50, 70, 25};
for (int k : keys) bst.insert(k);
System.out.println("inorder  : " + bst.inorder());
System.out.println("preorder : " + bst.preorder());
System.out.println("postorder: " + bst.postorder());
System.out.println("size=" + bst.size() + " height=" + bst.height()
        + " min=" + bst.findMin() + " max=" + bst.findMax());
System.out.println("contains 25? " + bst.contains(25)
        + ", contains 35? " + bst.contains(35));
bst.remove(20);
System.out.println("after remove(20): " + bst.preorder());
inorder  : 10 20 25 30 40 50 60 70
preorder : 40 20 10 30 25 60 50 70
postorder: 10 25 30 20 50 70 60 40
size=8 height=3 min=10 max=70
contains 25? true, contains 35? false
after remove(20): 40 25 10 30 60 50 70
  after insertion      after remove(20)
       40                    40
     /    \                /    \
   20      60            25      60
  /  \    /  \          /  \    /  \
10   30  50   70      10   30  50   70
     /
   25

The successor of 20 is the smallest of the right subtree, which is 25. The same class works with String too: inserting "mango kiwi apple peach banana" gives the inorder apple banana kiwi mango peach, because String’s compareTo is alphabetical.

Write-a-method patterns

Most of the methods asked in exams fit one of three patterns.

PatternWhenSkeletonTime
Accumulate over the whole treesize, sum, leaf count, count of one-child nodesnull → 0; your own contribution + f(L) + f(R)O(n)O(n)
Descend along one pathcontains, floor, ceiling, depth, LCAwhile (t != null) { compare, go left or right }O(h)O(h)
Traverse with pruningrange count/print, k-th elementskip one side depending on the conditionO(h+k)O(h + k)

The sample tree used in the questions on this page (the keys 50, 30, 70, 20, 40, 60, 80, 35, 45, 65 were inserted in this order):

            50
          /    \
        30      70
       /  \    /  \
     20   40  60   80
         / \    \
       35  45    65

TIP · think of the empty tree and the single node first. Run the method you wrote in your head on null and on a single leaf. If these two cases are right, the recursion usually handles the rest.

Common mistakes

WrongThe truth
insert(x, t.left);t.left = insert(x, t.left);
insert(x, root); inside insert(x)root = insert(x, root); (the first insertion changes the root)
if (x < t.element) on a generic typex.compareTo(t.element) < 0
compareTo(...) == -1< 0; only the sign is guaranteed
null → 0 in heightIn this course null → -1, a leaf is 0
Checking only the children in isBSTCarry the (lo, hi) range through the recursion
Forgetting to delete the successor in removet.right = remove(t.element, t.right);
Thinking findMin() returns null on an empty treeAccessing root.left throws NullPointerException; the public method must check first

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

What is printed? (BinarySearchTree is the class on this page; duplicates are ignored.)

BinarySearchTree<Integer> t = new BinarySearchTree<>();
for (int x : new int[]{33, 18, 47, 9, 25, 40, 52, 18}) t.insert(x);
System.out.println(t.findMin() + " " + t.findMax() + " " + t.size());
Show answer
9 52 7

There are 8 insertions, but the second 18 is already in the tree: in insert, compareTo returns 0 and nothing is done. size = 7.
findMin always goes left: 33 → 18 → 9. findMax always goes right: 33 → 47 → 52.Integer + " " is String concatenation, not addition: the output is "9 52 7".

Question 2medium

What is printed?

BinarySearchTree<Integer> u = new BinarySearchTree<>();
for (int x : new int[]{33, 18, 47, 9, 25, 40, 52, 21}) u.insert(x);
u.remove(18);
u.remove(47);
System.out.println(u.preorder());
System.out.println(u.height());
Show answer
33 21 9 25 52 40 / 2

The tree: 33(18(9, 25(21, -)), 47(40, 52)).
remove(18): two children. The min of the right subtree: 25 → 21. 21 is written in place of 18, and the leaf 21 is deleted: 33(21(9, 25), 47(40, 52)).
remove(47): two children. The min of the right subtree is 52 (no left child). 52 replaces 47 and the old 52 is deleted: 33(21(9, 25), 52(40, -)).
Preorder: 33 21 9 25 52 40. The height is 2 (33 → 21 → 9).
The output is two lines: 33 21 9 25 52 40 and 2.

Question 3medium

The keys 50, 30, 70, 20, 60, 80, 65, 10 are inserted into an empty BST. What does mystery(root) return, and what does the method compute?

static int mystery(Node t) {
    if (t == null) return 0;
    int c = ((t.left == null) != (t.right == null)) ? 1 : 0;
    return c + mystery(t.left) + mystery(t.right);
}
Show answer
3; the number of nodes with exactly one child

(t.left == null) != (t.right == null) is true only when exactly one child is null and the other is not. So the method counts the nodes with one child.
The tree: 50(30(20(10, -), -), 70(60(-, 65), 80)).
Nodes with one child: 30 (only 20), 20 (only 10), 60 (only 65). The result is 3.

Question 4medium

This insert method has a bug. After inserting 5, 3, 8, 1 with root = badInsert(root, x);, what does size(root) return? Fix the method.

static Node badInsert(Node t, int x) {
    if (t == null) return new Node(x);
    if (x < t.element) badInsert(t.left, x);
    else if (x > t.element) badInsert(t.right, x);
    return t;
}
Show answer
1; assign the results: t.left = badInsert(t.left, x) and t.right = badInsert(t.right, x)

In the first call root is null, so the new node (5) is returned and assigned to root. In later calls, badInsert(t.left, x) creates and returns the new node, but the returned value is not attached anywhere. Java passes references by value: assigning a new node to t inside the method does not change t.left in the caller.
Only 5 remains in the tree: size 1.
The correct code: t.left = badInsert(t.left, x); and t.right = badInsert(t.right, x);.

Question 5easy

Write a recursive method static int size(Node t) that returns the number of nodes in the tree rooted at t.

Show answer
null → 0, else 1 + size(left) + size(right)

static int size(Node t) {
    if (t == null) return 0;
    return 1 + size(t.left) + size(t.right);
}
Each node is counted once: O(n). For the 10-node sample tree on this page it returns 10.

Question 6medium

Write static int countInRange(Node t, int lo, int hi) that returns how many keys k satisfy lo ≤ k ≤ hi in a BST. Skip subtrees that cannot contain such keys. For the sample tree on this page, what does countInRange(root, 33, 62) return?

Show answer
5 (keys 35, 40, 45, 50, 60)

If the node is smaller than lo, everything in its left subtree is smaller too: look only right. If it is larger than hi, look only left. If it is in the range, count 1 and look at both sides.

static int countInRange(Node t, int lo, int hi) {
    if (t == null) return 0;
    if (t.element < lo) return countInRange(t.right, lo, hi);
    if (t.element > hi) return countInRange(t.left, lo, hi);
    return 1 + countInRange(t.left, lo, hi)
             + countInRange(t.right, lo, hi);
}
In the sample tree, the keys between 33 and 62 are 35, 40, 45, 50, 60: 5. Thanks to pruning, the time is O(h + number of answers).

Question 7hard

Write static boolean isBST(Node t) that checks the BST property correctly for every subtree. Why is checking only t.left.element < t.element < t.right.element at each node not enough?

Show answer
Pass allowed (lo, hi) bounds down the recursion; the child-only check misses deeper violations

Every node must fall into a range (lo, hi) coming from its ancestors. Going left, the upper bound becomes the node's value; going right, the lower bound does.

static boolean isBST(Node t) {
    return isBST(t, Long.MIN_VALUE, Long.MAX_VALUE);
}
static boolean isBST(Node t, long lo, long hi) {
    if (t == null) return true;
    if (t.element <= lo || t.element >= hi) return false;
    return isBST(t.left, lo, t.element)
        && isBST(t.right, t.element, hi);
}
The version that checks only the children returns true for the tree 50(30(-, 55), 70): 30 < 55 is correct, but 55 is in the left subtree of the root and is larger than 50. The correct version returns false. The long bounds prevent overflow for keys such as Integer.MIN_VALUE.

Question 8hard

Write static int kthSmallest(Node t, int k) that returns the k-th smallest key (k = 1 is the minimum) using an inorder traversal that stops early. For the sample tree, what is the 4th smallest key?

Show answer
40

Inorder visits in sorted order. Stop when the visit counter reaches k.

static int count, answer;
static int kthSmallest(Node t, int k) {
    count = 0; answer = -1;
    kth(t, k);
    return answer;
}
static void kth(Node t, int k) {
    if (t == null || count >= k) return;
    kth(t.left, k);
    if (++count == k) { answer = t.element; return; }
    kth(t.right, k);
}
The inorder of the sample tree is 20 30 35 40 45 ...: the 4th element is 40. Time O(h + k).

Question 9medium

Write an iterative method static Integer floor(Node t, int x) that returns the largest key ≤ x, or null if none exists. For the sample tree, what are floor(root, 58) and floor(root, 15)?

Show answer
50 and null

If the node equals x, the answer is that node. If the node is smaller than x, it is a candidate, and a larger candidate may be on the right. If it is larger, go left.

static Integer floor(Node t, int x) {
    Integer best = null;
    while (t != null) {
        if (t.element == x) return x;
        if (t.element < x) { best = t.element; t = t.right; }
        else t = t.left;
    }
    return best;
}
58: 50 < 58 so the candidate is 50, go right to 70; 70 > 58 go left to 60; 60 > 58 go left, the left of 60 is null: the answer is 50.
15: 50, 30, 20 are all larger, so it goes left until null and there is no candidate: null. Time O(h).

Question 10hard

Write static Node lca(Node t, int a, int b) that returns the lowest common ancestor of keys a and b (both present, a < b) in a BST. In the sample tree, what are lca(35, 45) and lca(35, 65)?

Show answer
40 and 50

If both are smaller than the node, the common ancestor is on the left; if both are larger, it is on the right. If one is on the left and the other on the right (or one is the node itself), this node is the split point.

static Node lca(Node t, int a, int b) {
    while (t != null) {
        if (a < t.element && b < t.element) t = t.left;
        else if (a > t.element && b > t.element) t = t.right;
        else return t;
    }
    return null;
}
35 and 45: both are smaller than 50 and larger than 30, and they split at 40. 35 and 65: they split at the root, 50. O(h).

Question 11easy

What is the time complexity of (a) printing all keys of a BST in sorted order, and (b) building a BST by inserting n keys that arrive already sorted?

Show answer
(a) O(n); (b) O(n^2)

(a) Inorder visits each node once: O(n), whatever the shape of the tree.
(b) When the i-th key is inserted, the tree is a right chain of i nodes, so i comparisons are made. The total is 0 + 1 + ... + (n-1) = n(n-1)/2. The program counted 4950 comparisons for n = 100. O(n^2).
Note: if you want a balanced BST from sorted data, make the middle element the root and build the two halves recursively: O(n).

Verification: The BinarySearchTree class in the body and all methods in the questions were written verbatim in verify/SEN2212/W04.java and run (java W04.java). The answers to the output questions are the program's output. size, isBST, countInRange, kthSmallest, floor, lca and the generic remove were compared against java.util.TreeSet on 2000 random trees.

Review card

class BinarySearchTree<T extends Comparable<? super T>> → compare with compareTo. BinaryNode: element, left, right. A private static nested class. Public driver: insert(x) { root = insert(x, root); } private recursive helper. RECURSIVE INSERT: t == null → return a new node. t.left = insert(x, t.left) (THE ASSIGNMENT IS REQUIRED). remove with two children: t.element = findMin(t.right).element; t.right = remove(t.element, t.right). remove with one/zero children: t = (t.left != null) ? t.left : t.right. size: null → 0, 1 + size(L) + size(R). height: null → -1, 1 + max(h(L), h(R)). isBST: carry a (lo, hi) range; checking only the children is NOT ENOUGH. countInRange: node < lo → only right; > hi → only left; in between → 1 + both sides. kth smallest: inorder + counter. floor: if smaller take it as a candidate, go right. LCA: both smaller → left, both larger → right, otherwise this node. All O(h); size/height/traversals O(n).