Divide and Conquer

Divide and conquer is a problem solving strategy that involves breaking the main problem into smaller sub-problems, solving the sub-problems, and combining these solutions into a solution of the main problem.

This is best illustrated via an example (inspired by a true story).

Suppose you work at a bank in the department for handing out cards. Each day you receive tens of new envelopes containing customers' cards, which you need to store safely until the custumers come to pick them up. This is a big bank, so at any given day, you may have hundreds of envelopes to keep. The account number of each client is printed on the envelope, and that is how you find someone's card when they come to collect it.

People are not known to be patient when they need to go to the bank, so you would like to find each person's card as fast as possible. How do you do that?

Certainly not by keeping all cards in a random order.

If the cards are sorted by the account number, it is much easier to find a certain card by the account. Can we devise a step-by-step algorithm to find the right envelope?

Suppose we are trying to find the card for account A. What do we know if we compare A with the envelope at the very middle?

In the last two situations, we are left with the same problem again, namely: finding the envelope A in a sorted set of envelopes. Except that, this time, the problem is smaller because we have eliminated half of the envelopes we need to search through!

Since we have the same problem again, can we apply the same technique? Yes, we can. We can again take the envelope in the middle of the chosen half and compare with A, and then eliminate 1/4 of envelopes. This can be done until we either find envelope A, or we run out of envelopes in which case we can say for sure that there is no envelope A (assuming the sorting was done properly!).

What we did above is a classical divide-and-conquer algorithm for search called binary search. Abstracting away the details (account numbers, cards, bank, etc), the core problem we were trying to solve is finding whether an element is in a sorted list. Observe that it is crucial for the list to be sorted.

This is a simple case because we "divide" one problem (the full sequence of envelopes) into only one other smaller problem (half the sequence of envelopes). We will see other cases where this division results into many sub-problems.

The implementation of binary search in python is:

Sort

Another recurrent problem in computing is how to sort data. Binary search is an incredibly powerful technique, but for that to work, the data needs to be sorted. Turns out we can also apply divide-and-conquer for sorting.

Suppose you have a really long list to sort. This is hard, so let's split the list in half. If you have the two halves sorted, can you combine them into one big sorted list?

This operation is called merge, and we can implement it like this:

So the quetion now is, how do we sort the two halves? Note that this is the same problem again, but smaller! So we can use the same technique: split the halves in halves, sort, and merge. This will keep going until we can no longer split the list, i.e. when it contains zero or one element. In that case, the list is already sorted, trivially.

This is a classic sorting algorithm, called merge sort.

Let's try to implement it.

The code above works, more or less, like merge sort. But it does not really convey the idea of having a sub-problem that we solve the same way. A much better way to implement this, closer to the concept, is via recursion.

Recursion

We call something recursive when it is defined in terms of itself. This may sound very redundant, but we have just used recursion to solve the problems of searching and sorting, and all solutions kind of made sense (I hope!).

One way to think of recursion is: if we brake something apart, and end up with pieces that have the exact same property as the whole, than this thing is recursive. Another way to think about recursion is: if I use the same rules of construction for an object again and again, it is recursive.

In programming, recursion usually means a function that calls itself.

Let's think about our two solutions above in terms of recursion.

When we were search for an element in a list, we compared it with the middle element, and then decided on which half we were supposed to continue the search. If we reach an empty list, the element is not there and we return False. That translates nicely into the code below. Note that there are no loops. Instead, once we decide where we need to search, we simply rely on the same function again to find the element. That is, we call it recursively.

Recursive sort

We can do the same exercise with merge sort. Our initial idea for a solution was: we split the list in half, sort it somehow, and merge. The "sort it somehow" part is when we make the recursive call to the same function.

Note that it is important to have a guard in the beginning, to return if we are at the smallest possible case. If we forget about this, the function will keep calling itself for eternity (or until your computer memory is over).

Fractals

Recursion also happens in nature, usually in the form of fractals. A fractal is a structure in which the same structure repeats inside itself.

Some examples include:

Romanesco broccoli

Nautilus

Fern

Ice crystals

Since we know how to write programs recursively, and how to draw things in python, we can draw fractals! These are usually defined via a recursive mathematical function. You can find the code below for generating Koch Crystals, and Barsnley fern, famous examples of fractals. (Don't worry about understanding the code, just note that it is recursive.)

Towers of Hanoi

The game towers of Hanoi consists on moving disks one at a time from one tower to another, using only one extra spare tower and never placing a bigger disk on top of a smaller one. We want to write a program for solving this problem for us. Before we do that, let’s spend more time looking at the problem and the solutions we can find for different number of disks. After playing with the towers for a while, we realize that the pattern is:

  1. Move the upper disks to the spare tower
  2. Move the biggest disk to the target tower
  3. Move the disks on the spare tower to the target one

If we think of this in terms of divide and conquer, we see that first we need to move n disks from tower 1 to 3, with 2 spare. For that, we need to move n-1 disks from tower 1 to 2, with 3 spare. For that we need to move n-2 disks from tower 1 to 3, with 2 spare... And so on and so forth.

Naturally, we can devise a recursive strategy:

Move n disks from t1 to t3 with t2 auxiliar tower:

Translating this solution into code: