Answers Built From Smaller Answers
Assume the Smaller Case Is Done
A problem becomes easy the moment you are allowed to assume the slightly smaller version is already answered, and the only work left is the step that combines.
A strange kind of permission
Here is a problem. Add up a list of a thousand numbers.
Here is a way to make it easy. Assume somebody has already added up the last
nine hundred and ninety nine of them and handed you the total. Now your whole
job is to add the first number to what they gave you. One addition.
That sounds like cheating, and the first reaction is usually to ask who added
up the nine hundred and ninety nine. The answer is that they used the same
permission: they assumed somebody had added up the last nine hundred and
ninety eight, and did one addition. And so on down.
The permission is not a trick. It is the whole method, and it is worth
noticing how much it removes. The problem of adding a thousand numbers has
become the problem of writing one addition and saying what happens when the
list is empty. Nothing else needs to be worked out, held in mind, or traced.
def total(xs):
if not xs:
return 0 # the smallest case, answered outright
return xs[0] + total(xs[1:]) # assume the rest is already added up
print(total([4, 8, 15, 16, 23, 42])) # 108
def longest(words):
if len(words) == 1:
return words[0]
rest = longest(words[1:]) # assume the longest of the rest is known
return words[0] if len(words[0]) > len(rest) else rest
print(longest(["pin", "thread", "board", "needle"])) # needleWhy the assumption is allowed
The uneasy feeling is worth taking seriously, because the assumption looks
circular. It is not, and the reason is that each use of it is applied to a
strictly smaller problem than the one before.
Think of it as a claim rather than as a procedure. The claim is: if the method
works on inputs of size , then it works on inputs of size . The
combining step is the proof of that claim. Separately, the method is checked
by hand on the smallest case. Those two facts together mean the method works
on every size, by the same reasoning that makes a row of dominoes fall: the
first one goes over, and each one knocks over the next.
- the method gives the right answer on every input of size n
This also explains why unrolling a recursion in your head is the wrong way to
read one. Following a thousand nested calls is impossible and following three
proves nothing. The useful reading is the claim: assuming the inner answer is
right, is the combining step right? That question can be answered in a few
seconds and it covers every size at once.
Choosing what smaller means
The hard part is not writing the combining step. It is deciding what smaller
is going to mean, because different choices lead to completely different
methods, and some choices lead nowhere at all.
For a list, the obvious smaller is the list without its first item. But the
list without its last item is also smaller, and so are the two halves, and so
is the list with the largest item removed. For a number, smaller might mean
one less, or half as much, or one digit shorter. For a grid, it might mean one
fewer row, or one quarter of the area.
| what the smaller problem | the combining step | work done combining | total cost | |
|---|---|---|---|---|
| remove the first item | 1 | 1 | 1 | 2 |
| remove the smallest item | 1 | 1 | 1 | 2 |
| split into halves | 1 | 1 | 1 | 1 |
| remove a random item | 1 | 1 | 1 | 1 |
The test for a good choice is whether the combining step can be written at
all, and whether it is genuinely cheaper than the original problem. Two
failures are common.
The first is a smaller problem whose answer does not help. Knowing the sorted
order of a list tells you nothing directly about whether it contains two items
that add to a hundred, so sorting as the smaller problem is a dead end for
that question unless the combining step does real work.
The second is a combining step that has to look at the whole input again. If
assembling the answer requires another full pass over everything, the problem
was not made smaller in any useful sense, and the method will cost as much as
doing it all directly.
The step that does the work
Once smaller is chosen, the combining step is where all the thinking goes, and
it is useful to write it as a sentence before writing it as code.
For counting the items in a list: the count is one more than the count of the
rest. For reversing: the reverse is the reverse of the rest, with the first
item put on the end. For the largest value: the largest is whichever is bigger
out of the first item and the largest of the rest. For the number of ways to
climb a staircase taking one or two steps at a time: the number of ways is the
ways from one step down plus the ways from two steps down, because the last
move was either a single or a double.
| step | problem | smaller case | the sentence | the step |
|---|---|---|---|---|
| 1 | add up a list | the list without its first | the total is the first plus the total of the rest | xs[0] + total(rest) |
| 2 | count the items | the list without its first | the count is one more than the count of the rest | 1 + count(rest) |
| 3 | reverse a list | the list without its first | the reverse is the reverse of the rest, then the first | reverse(rest) + [xs[0]] |
| 4 | ways up a staircase | a staircase one or two steps shorter | the last move was a single or a double | ways(n-1) + ways(n-2) |
| 5 | is it a palindrome | the word without its two ends | the ends must match and the middle must be one | ends equal and palin(middle) |
Two details in that table matter later. The staircase row calls itself twice,
and the palindrome row shrinks by two rather than by one. Neither is unusual.
A recursion may use any number of smaller answers and may shrink by any
amount, so long as it shrinks.
What this has bought, and what it has not
What has been bought is a way of writing a solution that fits in three lines
and is checkable in a few seconds. That is genuinely valuable, and for many
problems it is the end of the story.
What has not been bought is anything about cost. The methods in this lesson do
not say how much work happens, how much memory is held while the calls are
outstanding, or whether the same small problem is being solved repeatedly. The
staircase row in that last table, written exactly as it reads, is unusable
beyond about forty steps, and nothing in how it was derived gives any hint of
that.
The next three lessons are about what the machine is actually doing while all
this elegance runs, and the three after that are about the repair.
Recap
- The method is to assume the smaller version of the problem is already answered, and then write only the step that turns that answer into the full one.
- That assumption is not wishful thinking, because the same reasoning applied to the smaller case assumes a smaller one still, and the chain has to end somewhere.
- Most of the difficulty is in choosing what smaller means, since the wrong choice produces a step that cannot be written at all.
This is the reading half
Starting the course gives you your own copy of it. Every idea on every page has problems standing under it, marked with a reason rather than a tick, and any sentence you do not believe can be opened and argued with. None of that can happen on a page nobody owns.
The contents