Skip to content
dsa450for five year olds

Dynamic Programming · 50 of 60

Optimal Binary Search Tree

Arrange sorted keys into a search tree so that the keys people ask for most often sit near the top and the total number of search steps is as small as possible.

Simple way
time O(3^n)
room O(n)
Clever way
time O(n^3)
room O(n^2)

The problem, simply

You get sorted keys, for example 10, 12, 20, and for each key how many times people search for it, for example 34, 8, 50.

You must build a binary search tree from these keys: smaller keys go to the left of a node and bigger keys to the right. Finding a key at depth d (the top is depth 1) takes d steps. The total cost is, for every key, its search count times its depth. Give back the smallest total cost you can get.

A story to remember it

Picture it

tree
20101250 x 134 x 28 x 3

The best tree for keys 10, 12, 20 searched 34, 8 and 50 times. 20 is on top (1 step), 10 is at depth 2, and 12 is at depth 3. Cost = 50 x 1 + 34 x 2 + 8 x 3 = 142.

  • answer

A nicely balanced tree is not the best here. With 12 on top the cost is 8 x 1 + 34 x 2 + 50 x 2 = 176, because the popular key 20 sits one step lower.

Examples

Three keys

In
keys = [10, 12, 20], freq = [34, 8, 50]
Out
142

Put 20 on top, then 10, then 12 under 10.

Two keys

In
keys = [10, 12], freq = [34, 50]
Out
118

12 on top costs 50 x 1 + 34 x 2 = 118. 10 on top would cost 34 + 50 x 2 = 134.

Most popular on top is a trap

In
keys = [1, 2, 3, 4], freq = [3, 2, 2, 2]
Out
18

Putting the most searched key 1 on top gives 21. Putting 2 on top, 1 on its left and 3 then 4 on its right gives 3 x 2 + 2 x 1 + 2 x 2 + 2 x 3 = 18.

All equal

In
keys = [1, 2, 3], freq = [1, 1, 1]
Out
5

When every key is equally popular, the balanced tree wins: 1 + 2 + 2.

One key

In
keys = [5], freq = [7]
Out
7

A single key is the top. One step for each of its 7 searches.

Way 1: the simple way

Write a helper cost(i, j): the best cost for the keys from position i to position j.

  1. If there are no keys (i > j), the cost is 0.
  2. Try every key r in the group as the top.
  3. The left group i..r-1 and the right group r+1..j hang one step lower. Their best costs come from the helper, and every key in the whole group pays one more step, so add the sum of all their searches once.
  4. Keep the smallest total.

Why add the sum once? Before, each key's depth was counted inside its own small tree. Hanging that tree under a new top makes every key one step deeper, which costs exactly its search count one more time. The top key itself also pays its count once, so the whole group's sum covers everyone.

treecost(first key, last key)
10122012122010120,21,22,21,10,02,20,11,10,0againagainagain

The helper calls for keys at positions 0 to 2. The tag on each line is the key tried on top. The highlighted groups, such as (1,1) for key 12, are solved more than once.

  • checking
java
class Solution {
    public int optimalCost(int[] keys, int[] freq) {
        return cost(freq, 0, keys.length - 1);
    }

    private int cost(int[] freq, int i, int j) {
        if (i > j) return 0;                      // no keys, no cost
        int groupSum = 0;
        for (int k = i; k <= j; k++) groupSum += freq[k];
        int best = Integer.MAX_VALUE;
        for (int r = i; r <= j; r++) {            // try each key on top
            int total = cost(freq, i, r - 1) + cost(freq, r + 1, j) + groupSum;
            best = Math.min(best, total);
        }
        return best;
    }
}

How long it takes: each call splits into two smaller calls for every possible top. The number of calls grows like O(3^n). Extra room: the waiting helpers, O(n).

Way 2: the clever way

Every helper call is a stretch of keys from i to j. There are only about n * n / 2 stretches. Solve them all once, shortest first, and keep the answers in a table cost[i][j].

  1. Stretches of one key: cost[i][i] = freq[i].
  2. For length 2, then 3, and so on: for each stretch, try every key r as the top, and read the two smaller stretches from the table.
  3. cost[i][j] = min over r of (cost[i][r-1] + cost[r+1][j]) + sum of freq[i..j].
  4. The answer sits in the top right corner, cost[0][n-1].

A running total (prefix sums) gives the sum of any stretch in one step.

Fill the table from short stretches to the whole row of keys

01 / 11

gridcost[first key][last key]
10122010122034850
mapsearches per key
10341282050

One key alone sits at the top, so each search for it takes 1 step. Its cost is its own search count: 34, 8 and 50 on the diagonal.

  • looking now
  • checking
  • done
  • answer
  • just added
java
class Solution {
    public int optimalCost(int[] keys, int[] freq) {
        int n = keys.length;
        if (n == 0) return 0;
        int[] pre = new int[n + 1];                  // running totals of freq
        for (int i = 0; i < n; i++) pre[i + 1] = pre[i] + freq[i];

        int[][] cost = new int[n][n];
        for (int i = 0; i < n; i++) cost[i][i] = freq[i];   // one key alone

        for (int len = 2; len <= n; len++) {
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                int groupSum = pre[j + 1] - pre[i];
                cost[i][j] = Integer.MAX_VALUE;
                for (int r = i; r <= j; r++) {            // try each key on top
                    int left = (r > i) ? cost[i][r - 1] : 0;
                    int right = (r < j) ? cost[r + 1][j] : 0;
                    cost[i][j] = Math.min(cost[i][j], left + right + groupSum);
                }
            }
        }
        return cost[0][n - 1];
    }
}

How long it takes: about n * n / 2 stretches, and each one tries up to n tops, so O(n^3). Extra room: the table, O(n^2).

Watch out for

  • Forgetting to add the group sum. Without it, depth never gets counted and every answer looks too small.
  • Fill by stretch length, not by row. cost[i][j] needs shorter stretches like cost[i+1][j], which a plain top to bottom row loop has not filled yet.
  • Empty sides. When r == i or r == j, one side has no keys and costs 0. Do not read cost[i][i-1].
  • Some versions also count failed searches (keys not in the tree). Then each gap between keys gets its own count too, but the table idea is the same.

What interviewers ask next

  • Can you build the tree, not only the cost? Store the best top root[i][j] for every stretch, then build recursively: the top of 0..n-1, then the tops of its left and right stretches.
  • Can you make it faster? Yes. Knuth noticed that the best top for i..j lies between the best tops of i..j-1 and i+1..j. Only trying those cuts the time to O(n^2).
  • How is this like matrix chain multiplication? Both try every split point inside a stretch and combine two smaller stretches. They are the same family, interval DP.
  • What if the searches change over time? Then a self adjusting tree, such as a splay tree, moves popular keys up as they get searched.

Remember this

Grown-up words: the signposts form a binary search tree, the search counts are frequencies, a stretch of keys is an interval, the table is interval dynamic programming, the running totals are prefix sums, and the faster trick is Knuth's optimization.