There is a village consisting of many pair-bonded couples that straddles a river. Occasionally, n ≥ 2 couples need to cross a river to conduct business. For example:
H1 W1 H2 W2 | |
They have a boat that holds at most 2 people at a time.
The husbands are a jealous lot. No husband will allow his wife to be on the same bank as another man unless he himself is there. ... even if there are lots of other people on the same bank. ... even if the other man's wife is there, too!
So, this is a legal configuration:
H1 W1 H2 | | W2
But this is not:
W1 H2 W2 | | H1
Clearly, these folks have some trust issues.
Your job is to engineer a solution that helps them survive in spite of their pathology. Let's attack the problem from bottom up. Your tasks are:
The couples are jealous, but they are also cheap. They want to know:
How many river crossings are required for n-couple transfers?
Suggestion: Draw pictures. Doing so helped me.
(Perhaps that's what they're all worried about.)
We could attack this problem with brute force. This would involve trying all possible combinations of crossings.
This, of course, leads to an explosion of possible paths combinations. This case is worse than usual, though: most of combinations travel through invalid states.
This problem benefits from a representation change. If we turn it into a graph problem, we will find that the problem is straightforward, and doesn't even require much search.
In our graph, each node represents a state of the world, with the arrow indicating which direction the boat is traveling. Each edge represents a legal transfer in the direction of the boat.
The edges are undirected, because each move is reversible.
Some states are dead ends and do not need to be considered further. In graph terminology, we say that they do not need to be "expanded". Consider the leftmost node in level two above. Neither husband can go back to the other side alone, because he would be on the same side with the other man's wife without the other man. So there are "new" moves out of this state, but none are legal.
Other states are similarly uninteresting, because the only possible move takes the couples back to the previous state. We don't need to consider these states, either. The rightmost node in level two is an example of this phenomenon.
The diagram above shows one example of each kind of uninteresting node but doesn't show other such states for level 2, or any such states lower in the graph.
By focusing only on interesting moves, the resulting graph is a nearly direct walk from the starting condition to the desired condition. There are four possible paths, because the graph has two binary decision points. Each requires five crossings. Here is one:
W1W2 W1 H1H2 H1 H1W1
At a high level, we might describe solving the two-couple case as:
choose a couple to move first move both wives across move the other wife back move both husbands across move the other husband back move the other couple
Now, what if n = 3? We can simplify the three-couple problem into a two-couple problem! The idea is again to move one couple, but this time making sure the boat ends up on the original side of the river.
Here is the problem as a graph, showing only the interesting moves. Notice that it works its way down the left side and back up the right...
Moving one couple and leaving the boat on the original side of the river takes six moves. Here is an example:
H3W3 H3 W1W2 W1 H2H3 H2W2
At a high level, we can describe the transformation from three couples to two as:
choose a couple h-i:w-i to move move w-i and another wife across move the other wife back move both remaining wives across move one of the other wives back move h-i and another husband across move the other couple back
Now we face our original problem, where n = 2. We can use the five-crossing sequence above to bring the other two couples across. This results in an 11-crossing solution, for which there are 4x4=16 different paths.
Solving for n ≥ 4 couples can be accomplished in similar fashion: Use the six-crossing sequence to simplify an n-couple problem into an n-1-couple problem. As soon as we have a two-couple problem, we use our five-crossing sequence to finish the task.
We have fulfilled our contract, and marital discord is delayed for another day.
Our approach is good, old decrease-and-conquer. In terms of problem transformation, we call this instance simplification. We take a problem and transform it into a simpler problem of exactly the same form. (Not all instance simplifications involve solving the decreased element.)
There is one small matter left to be solved. How many river crossings are required in our solution for n-couple transfers? To solve this, we can set up a recurrence relation:
C(n) = 6 + C(n-1) for n > 2 C(2) = 5
As always, we solve the recurrence by working backwards:
C(n) = 6 + C(n-1) = 6 + [ 6 + C(n-2) ] = 2(6) + C(n-2) = 12 + [ 6 + C(n-3) ] = 3(6) + C(n-3) = 18 + [ 6 + C(n-4) ] = 4(6) + C(n-4) ... = 6i + C(n-i) now, let i = n-2 ... = 6(n-2) + C(n-(n-2)) = 6(n-2) + C(2) = 6(n-2) + 5
This approach results in a well-behaved θ(n) algorithm, with reasonable constants.
Sometimes, solving a problem as stated can be quite daunting. There may be no obvious way to generate an efficient algorithm. But if we translate the problem into a different form, we may be able more easily to generate an efficient algorithm.
Our solution to the Jealous Husbands problem used two powerful transform-and-conquer techniques:
A third transform-and-conquer technique, problem reduction, makes a bigger transformation. It changes the nature of the problem altogether.
Reducing one problem into another can help us to generate an algorithm that looks much different than we might expect. In some situations, this may be the only way we have to generate an algorithm. Even when we do have alternatives, problem reduction is a great way to stretch your mind, getting practice in using analogies to make creative leaps from one problem space to another.
Consider the problem of computing least common multiple of two integers, m and n. lcm(m, n) is the smallest number evenly divisible by both m and n. For simplicity, we will assume that m is the larger of the two.
We can attack this problem with brute force by searching the sequence m+, m+1, m+2, ..., until we find a number that is divisible by both m and n. This requires doing a lot of divisions: O(m²) for naive algorithms, or approximately O(m1.585) for our divide-and-conquer approach. And remember, the latter algorithm improves on the former only for very large numbers!
Divisions are at least as costly as multiplications, so this is not an attractive solution. Can we do better?
We could use brute force in a different way:
For example, suppose m = 16 and n = 24.
This algorithm operates in O(n²) time and, worse, requires a potentially long list of prime numbers.
Do these two algorithms and their weaknesses sound familiar? They might, because we saw a similar problem back in Session 4: gcd(m, n). Remembering that problem can help us now.
Let's use a little Arithmetic Truth:
gcd(m,n) * lcm(m,n) = m * n
Which means that:
m * n lcm(m,n) = -------- gcd(m,n)
This truth converts our problem into a different problem: find gcd(m, n), then solve for lcm(m, n) using one more multiplication and one more division. We already have an efficient algorithm, for gcd(m, n), courtesy of Session 4: Euclid's algorithm.
Nice. We will use problem reduction again when solving some tougher problems later in the course.
You are given an array of n numbers and an integer s.
Your job: Write an algorithm that returns true if and only if the array contains two numbers whose sum is s.
Again, the brute force approach is straightforward: try all combinations of ni + nj. This will require ½n(n-1) comparisons, which is θ(n²). Surely we can do better...
We can using transform-and-conquer. First, sort the list, which is an O(n log n) operation. Starting with a sorted array, we have an O(n) approach for finding a solution.
Suppose s = 0. We just need to find two numbers with same absolute value but different signs: ni + (-ni). If we sort the array in non-decreasing order of absolute value, we can look for consecutive values that satisfy this condition!
For example, we might start with this array:
[26, 42, -17, 47, -26, 1006]
We sort the array in ascending order of absolute value:
[-17, 26, -26, 42, 47, 1006]
Now, we walk down list until we see 26 and -26 together and choose them as our answer.
Of course, s doesn't have to be 0. Let's transform and conquer one more time. If ni + nj = s, then (ni-k) + (nj-k) = (s-2k). If we let k = s/2), this becomes (ni-s/2) + (nj-2/2) = 0. Our simple sort-and-scan algorithm now solves the problem!
So, our final algorithm is
INPUT array A[1..n] of integers integer s sort array in non-decreasing order of absolute value for every A[i] A[i] = A[i] - s/2 for every pair A[i], A[i+1] if A[i], A[i+1] = 0 return A[i], A[i+1] fail
So using two representation changes:
(Even better, we may be able to do the search at the same time as we sort, because an insertion sort would make the same sequence of comparisons that our pair search needs to make. Implemented this way, the algorithm could short-circuit as soon as it finds a successful pair.)
Students in a previous semester proposed an intriguing approach. I call it the RNB algorithm, in honor of its creators Ryan, Noyce, and Berns:
// Input: array of numbers a[0..n-1], number s // Output: whether or not s can be made by summing 2 numbers sort A using a sort that is O(n log n) i = 0 j = n-1 while i < j if a[i] + a[j] > s j = j - 1 else if a[i] + a[j] < s i = i + 1 else return a[i], a[j] fail
The idea is to walk two "pointers" from the ends of the array toward the middle, looking at sums along the way. We move the pointers based on the direction of the "error" between the sum and the target sum.
Does this always work? I think so, but I haven't proven it yet... Can you prove it, or create a counterexample?
Representation change and instance simplification can do almost magical things to a hard problem. (The AI scientist in me wonders if they are the basis of most creative problem solving.) The trick: knowing lots of different representations, and developing some intuition about when to use them!
I love one of Patrick Henry Winston's principles from the study of artificial intelligence:
Choose the right representation, and the problem is nearly solved.
It is oh, so true -- and oh, so like a siren. If only we can choose the right representation, everything is a search problem -- or logic problem, or a means-ends problem, or.... In general, though, it turns out that choosing the "right representation" is often as hard as, or harder than, the original problem!
Presorting is an especially useful form of representation change when dealing with lists.
We saw an example last time where presorting can help: determining if all items in a list of values are unique.
We can use a similar approach to find the median of a list: presort, then go to position n/2 in the array. The Quickselect algorithm in our first session optimizes on this approach. It sorts only until it finds the value its needs.
Perhaps more surprising is that we can use this approach to find the mode of the list, the item that occurs most frequently in the list. After we presort the list, we can make a single pass to find the item that has the longest "run" in the sorted list!
An implicit assumption in presorting-based transformations is that the values in the list are comparable. That's true by definition for the median problem, but not for the mode. (Can you think of some common data that are incomparable?)