Version: 0.3.38
Optimization#
- Module:
Optimization
- Git Repository:
- Objectives:
Understand how to use recursion to approach optimization problem
Understand how to apply dynamic programming
Given two words, say “dog” and “cat”, our job is to find the minimum number of editions that transforms the first word into the second. There are various type of edit distance, depending on what “edition” we consider. In the following, we will study the Levenshtein distance, which restricts editions to adding character, removing character, and replacing a character by another 1See for instance Skiena, S. S. (2020). The algorithm design manual. : Springer International Publishing. Chapter 10.2. Here are a few examples:
“dog” can be transformed to “cat” by a minimum of 3 editions, namely converting ’d’ to ’c’, ’o’ to ’a’, and ’g’ to ’t’.
”sunday” can be transformed to ”monday” by a minimum of 2 editions, namely converting ’s’ to ’m’, ’u’ to ’o’.
”sun” can be transformed to ”sunday” by a minimum of 3 editions, namely adding ’d’, ’a’, and ’y’.
“aloud” can be transformed into “allowed” in three steps: Inserting a second ’l’ between the ’l’ and the ’o’, replacing the ’u’ with a ’w’, and finally inserting an ’e’ between the ’w’ and the ’d’.
Recursion#
Let us start with a “naive” implementation of the Levenshtein distance, with three kinds of edition: Addition, deletion and replacement.
Exercise
What would be the “base cases” of the recursion?
Solution to Exercise
The base case is when one of the two words is “empty”. In that case, we know that we will need to add (resp. to delete) all the character of the other word. For example to transform “cat” into ““, we need to delete the three letters ’c’, ’a’, ’t’.
Exercise
What would be the “recursive cases” of the recursion?
Solution to Exercise
Consider the distance \(d(c_1w_1, c_2w_2)\), where \(c_i\) represents a single character and \(w_i\) the rest of the word.
If the two first characters \(c_1\) and \(c_2\) are the same, there is no need to modify any of them and the distance is just the distance between the two remaining words \(w_1\) and \(w_2\).
Otherwise, we need to explore all possible path of actions, including replacing the characters \(1 + d(w_1, w_2)\), adding \(c_1\) (or deleting \(c_2\)) \(1 + d(c_1w_1, w_2)\) or adding \(c_2\) (or deleting \(c_1\)) \(1 + d(w_1, c_2w_2)\).
Exercise
Implement a recursive implementation of the Levenshtein distance problem.
Complete the file
WithRecursion.java
.The test suite
WithRecursionTest.java
may help you get your algorithm right.
Solution to Exercise
The code below shows how to implement the base and recursive cases we
found in the previous questions. The minimumOf
is a “custom”
function that finds the minimum in a list.
public int distance(String left, String right) {
if (left.isEmpty())
return right.length();
if (right.isEmpty())
return left.length();
if (left.charAt(0) == right.charAt(0))
return distance(left.substring(1), right.substring(1));
var candidates = new ArrayList<Integer>(3);
candidates.add(distance(left.substring(1), right.substring(1)));
candidates.add(distance(left, right.substring(1)));
candidates.add(distance(left.substring(1), right));
return 1 + minimumOf(candidates);
}
Memoization#
In this section, we will improve our recursive solution with memoization, that is avoiding solving multiple times the same sub problems.
Exercise
Instrument your recursive implementation to display the tree structure formed by the recursive calls. Can you see any recurrent sub problems, that would justify the need for memoïzation?
Solution to Exercise
One way to instrument the code is to display the problem that is solved and the solution, indented with respect to the recursion. The code below show changes to the recursive implementation.
1 private int depth;
2
3 public int distance(String left, String right) {
4 open();
5 display(String.format("ED('%s','%s')", left, right));
6 int result = 0;
7 if (left.isEmpty()) {
8 result = right.length();
9
10 } else if (right.isEmpty()) {
11 result = left.length();
12
13 } else if (left.charAt(0) == right.charAt(0)) {
14 result = distance(left.substring(1),
15 right.substring(1));
16
17 } else {
18 var candidates = new ArrayList<Integer>(3);
19 candidates.add(distance(left.substring(1),
20 right.substring(1)));
21 candidates.add(distance(left, right.substring(1)));
22 candidates.add(distance(left.substring(1), right));
23 result = 1 + minimumOf(candidates);
24 }
25 close();
26 return result;
27 }
28
29 void open() { depth++; }
30
31 void close() { depth--; }
32
33 void display(String message) {
34 System.out.println(" ".repeat(depth) + message);
35 }
Running this code on “cat”, and “dog” yields the following output, where we thus see that we are solving multiple time the same problems.
1 ED('cat','dog')
2 ED('at','og')
3 ED('t','g') // Once
4 ED('','')
5 ED('t','')
6 ED('','g')
7 ED('at','g')
8 ED('t','')
9 ED('at','')
10 ED('t','g') // once again
11 ED('','')
12 ED('t','')
13 ED('','g')
14 // ... cut for the sake of conciseness
Exercise
Implement memoization for the Levenshtein distance.
Create a class to represent a single sub problem.
Override the
equals
andhashCode
so that we can store such problem and their solution into a hash table.Starting from your recursive code, complete the file
WithMemoization.java
by storing solved sub problems and their solution.The test suite
WithMemoizationTest.java
can help you test your implementation.
Solution to Exercise
We create a Pair
class to capture a single invocation of the edit
distance. The key point is that to be able to use this as a key in a
hash table, this object has to be immutable (all fields are final)
and we need to override both equals
and hashCode
.
1 class Pair {
2
3 private final String left;
4 private final String right;
5
6 Pair(String left, String right) {
7 this.left = left;
8 this.right = right;
9 }
10
11 @Override
12 public int hashCode() {
13 return 17 * left.hashCode()
14 + 31 * right.hashCode();
15 }
16
17 @Override
18 public boolean equals(Object other) {
19 if (!(other instanceof Pair)) {
20 return false;
21 }
22 var otherPair = (Pair) other;
23 return left.equals(otherPair.left)
24 && right.equals(otherPair.right);
25 }
26
27 }
We this class, we can now equip our edit distance class with an hash table and check—before to recurse—if the problem has not already been solve.
1 private Map<Pair, Integer> memory;
2
3 public EDWithMemoization() {
4 this.memory = new HashMap<Pair, Integer>();
5 }
6
7 public int distance(String left, String right) {
8 var key = new Pair(left, right);
9 if (memory.containsKey(key))
10 return memory.get(key);
11
12 int result = 0;
13 if (left.isEmpty()) {
14 result = right.length();
15
16 } else if (right.isEmpty()) {
17 result = left.length();
18
19 } else if (left.charAt(0) == right.charAt(0)) {
20 result = distance(left.substring(1), right.substring(1));
21
22 } else {
23 var candidates = new ArrayList<Integer>(3);
24 candidates.add(distance(left.substring(1), right.substring(1)));
25 candidates.add(distance(left, right.substring(1)));
26 candidates.add(distance(left.substring(1), right));
27 result = 1 + minimumOf(candidates);
28 }
29 memory.put(key, result);
30 return result;
31 }
Exercise
Use the Benchmark.java
class to compare the speed of your
recursive and memoized implementation. Check that your two solution
output the same edit distance (see Appendix 4
for info about how to run the benchmark).
Solution to Exercise
By running the benchmark class, we can see that our memoization is able to cope much larger problem. Note by default, the function are stopped (i.e., time out thrown) after 20 seconds.
1% mvn compile \
2 exec:java --quiet \
3 -Dexec.mainClass="no.ntnu.idata2302.lab06.Benchmark"
4Length Recursion Memoization Dyn. Prog
5 time ED time ED time ED
6 5 0 4 0 4 error n/a
7 10 10 8 9 8 error n/a
8 50 timeout n/a 2 36 error n/a
9 100 timeout n/a 6 67 error n/a
10 500 timeout n/a 671 335 error n/a
11 1000 timeout n/a 4054 693 error n/a
Dynamic Programming#
We now further improve our solution, using dynamic programming to get rid of the recursive calls and, in turn, minimize the memory we consumed.
Exercise
How would you organize the sub problems into a “table”?
Where would the “base” cases be in this table?
Where would the “recursive” cases be?
How would you update a cell in this table?
Solution to Exercise
The edit distance problem is such that it is possible to lay down all sub problems into a \(n \times m\) matrix where \(n-1\) and \(m-1\) relates to the length of the two words to compare. The indices in this tables relate to the their suffixes. Consider for instance, “dog” and “cats” again. The word “dog” as 4 suffixes, and the word “cats” (note the plural form) 5 suffixes. In the cell \((2,1)\), we place the distance between the suffix “–g” (suffix at \(n-2\)) and “–ats” (suffix at \(m-1\)).
The base cases, which capture comparisons with an empty suffix (the last suffix) therefore occupy the first row and the first column. Provided we denote by \(c_i\) the first character of suffix \(i\), we can derive the update rules from our recursive definition:
Exercise
Implement dynamic programming for the edit distance.
Complete the file
WithDP.Java
.The test suite
WithDPTest.java
can help you test your implementation.
Solution to Exercise
The DP algorithm directly creates the matrix we described in the previous question and then fills compute all the possible values. Once the matrix is filled, it simply returns the last cell, where the answer to the original problem lays. The code below shows a possible implementation.
1 public int distance(String left, String right) {
2 int [][] distance =
3 new int[left.length()+1][right.length()+1];
4
5 for (int row=0 ; row<left.length()+1 ; row++) {
6 distance[row][0] = row;
7 }
8
9 for (int column=0 ; column<right.length()+1 ; column++) {
10 distance[0][column] = column;
11 }
12
13 for (int row=1 ; row<left.length()+1 ; row++) {
14 for (int column=1 ; column<right.length()+1 ; column++) {
15 int result =
16 1 + Math.min(Math.min(distance[row-1][column],
17 distance[row][column-1]),
18 distance[row-1][column-1]);
19 char leftHead = left.charAt(left.length()-row);
20 char rightHead = right.charAt(right.length()-column);
21 if (leftHead == rightHead)
22 result = distance[row-1][column-1];
23 distance[row][column] = result;
24 }
25 }
26
27 return distance[left.length()][right.length()];
28 }
Exercise
Use the Benchmark.java
class to compare the speed of your
memoized and DP implementation. Check that your two solutions
output the same edit distance.
Solution to Exercise
We can use the Benchmark
class again to assess the speed gain
brought by dynamic programming. Note the benchmark only runs once
every algorithm, so the time measures may vary due to garbage
collection or operating system interruptions. For instance, when I
ran it, DP was faster for a characters than for 500.
1% mvn compile \
2 exec:java --quiet \
3 -Dexec.mainClass="no.ntnu.idata2302.lab06.Benchmark"
4Length Recursion Memoization Dyn. Prog
5 time ED time ED time ED
6 5 1 4 0 4 1 4
7 10 58 7 0 7 1 7
8 50 timeout n/a 1 32 1 32
9 100 timeout n/a 5 69 3 69
10 500 timeout n/a 516 342 18 342
11 1000 timeout n/a 3986 662 10 662
Benchmark#
The code available in the Github repository includes a simple Java class that runs the three implementation (recursive, with memoization, and with dynamic programming) against random words of increasing length (10, 50, 100, 500, and characters). If the run takes more than 20 seconds, it is considered as timed out. We can run this benchmark with the command:
1% mvn compile \
2 exec:java --quiet \
3 -Dexec.mainClass="no.ntnu.idata2302.lab06.Benchmark"