Opening a black box you didn't know you had.
I want to show you my favorite formula ever. I never saw any programmer use it, ever. I did use it once. I could have done the same work in 2 minutes, but I decided to take 30+ minutes to have a more precise result (and because it was a lot more fun this way).
It can be used to estimate performance on a particular class of algorithms. Basically it's a stronger but harder to calculate version of the big O notation. It's not particularly useful since you can get basically the same result with big O notation or with the Master Theorem, but I still find this formula cool, so I want to show it.
Here it is, in all its majesty:
This is called the "General Formula for Divide and Conquer Algorithms"
If when seeing this you got scared this post is not for you. If you want to learn how this beast works, then this is the perfect post for you, because we're about to delve much deeper.
If you've read carefully then you might already know when this formula can be used. It's used for a class of programming algorithms called "Divide and Conquer". Basically an algorithm is said to be in that class when the algorithm splits a given problem instance into smaller easier to solve instances, solves those, and then fuses them back together into a final solution. That's exactly where the name comes from. The first is called the Divide step, while the last is the Conquer step. Many famous algorithms are exactly in this class of problems, like multiplying two matrices, the famous merge sort, making the exponential faster or the famous Fast Fourier Transform.
When you have a Divide and Conquer algorithm you can almost always use the General Formula. You only have 2 other restrictions:
- Each instance of size n divides into the same number of sub-instances. This is basically always true, it's like asking the algorithm to not be random. For example for merge sort the array will be always split into 2 smaller ones, Strassen's algorithm for multiplying two matrices always divides into 7 smaller ones etc.
- Each smaller sub-instance you divide into has the same size.
This is NOT always true. Yeah when merge sort divides the array you get 2 smaller ones, but if the bigger array has an odd size you get an odd and an even sized array. You cannot divide an array of 13 elements into 2 arrays of the same size, the best you can do is one of size 6 and one of size 7. Sometimes you can work around this problem, for example using merge sort if the array has size
$2^n$ then you'll always divide it cleanly.
If you can guarantee these 2 restrictions then you'll be able to write your algorithm complexity into his compact form:
Since
As an example, during merge sort,
Merge sort can then be written as
When you have the algorithm into this compact form you can almost immediately proceed with using the General Formula to calculate it's precise complexity. You know that big O complexity ignores constants right? If you have 2 O(n) algorithm can you tell me which is the fastest without writing them? For example you algorithms could have a complexity of 2n-1 and 2n+3 and you wouldn't immediately know. This is where the General Formula comes into play. By using that you would get all those constants back, knowing the algorithm's precise complexity.
I never saw any programmer use this, maybe because it's too scary to understand? Well I think it's time to change that.
As I said, if you have the algorithm into its compact form you can immediately start to calculate the exact algorithm complexity.
Now that we know the compact form let's look at the algorithm once again:
As you can see we have almost everything we need. We just need to calculate
Here we'll show how to calculate
First we need to understand what it's purpose is. Each time during the divide step of the algorithm, the function gets recursively called with a smaller problem to solve. If you really think about it you should realize this makes a tree of recursive calls, each one with a smaller size, something like this:
flowchart TD
id1(( )) --> id2(( ))
id1(( )) --> id3(( ))
id2(( )) --> id4(( ))
id2(( )) --> id5(( ))
id3(( )) --> id6(( ))
id3(( )) --> id7(( ))
id4(( )) --> id8(( ))
id4(( )) --> id9(( ))
id5(( )) --> id10(( ))
id5(( )) --> id11(( ))
id6(( )) --> id12(( ))
id6(( )) --> id13(( ))
id7(( )) --> id14(( ))
id7(( )) --> id15(( ))
id16((0)) --> id17(( ))
id17((...)) --> id18(( ))
id18((f*)) --> id19((f*+1))
Where the first node is the first call at level 0, then as you go further down the size of the instances decreases by one contraction
In it's rawest form:
or said another way:
Basically the iterate is "what do I get if I apply the function to itself x times?"
It's best to see with examples.
Say we have
Now a more complex example, say we have
It should be intuitive why we're doing this right? The algorithm divides the input into smaller and smaller pieces, it shouldn't be a surprise that we need to do the same to know how complex is the algorithm. We're basically creating a formula for "at step x the array will have a size of
Here we'll show how to calculate
Again,
In it's raw form:
The final layer would be when
Let's look at the same examples as before:
and a more complex:
Yes that
Look at what really happened.
If the size decreases by 1 each time (
During merge sort the array halves each time (
Using the iterate of the contraction and
We are ready to combine everything to create the General Formula. As a quick refresher, the formula is used to calculate the total work of a Divide and Conquer algorithm.
Recall the algorithm compact form:
You start the algorithm with a single instance of the problem (like reordering an array using merge sort).
How much work does that instance need to do? Since it's too big you'll need to divide it, solve those and then do some work to join them back together. So at level 0 the single node has to do
If you do this for each level and track everything into a table you get something like this.
node type | level | size | num nodes per lvl | work per node |
---|---|---|---|---|
internal | 0 | |||
internal | 1 | |||
internal | 2 | |||
... | ... | ... | ... | ... |
internal | ||||
leaf |
Ok, now the final step. What's the total work of the algorithm? Well it's 2 parts: the work of every internal node + the work for each leaf node.
Let's start with the second since it's easier. Just look at the table, bottom row, last column. It's
Now what's the total work for each internal node at a single layer? It's just the work of a node at that layer, times the number of nodes in that layer.
What's the work for all internal nodes?
It's just the sum of work for nodes at each layer
And finally: What's the work for the entire algorithm? It's the sum of the work for internal nodes and for leaf nodes The general formula:
To finish the work done in this post, let's calculate the total work done by an algorithm, from start to finish.
Remember what I said at the start? That I've used this formula to calculate the performance of an algorithm in 30 minutes instead of 2? Well let's see that exact example.
Let's say your into game development like me. You might already know what Bezier curves are. If you don't I'd suggest looking them up on the internet.
Long story short, If you want to create a Bezier curves out of
The trivial step is when the list has 2 points, that's just lerp(list[0], list[1], param)
, which is easy to calculate.
You might see how this is REALLY inefficient. Each time you need to calculate 2 new points on a list just smaller by 1. 2 times 2 times 2 over and over again is basically a
Without getting into the code, here's the algorithm compressed form:
Now let's calculate the work using the general formula.
First:
Second:
And finally: the General Formula
One piece at a time: The part on the right is simpler so let's start with that
And the left part one piece at a time
And putting
And that's it.
I could have said "Yeah it's
I still think it was worth it.
So there it is. That's my favorite formula ever.
I think people gets scared too easily, i mean, it wasn't so hard to calculate the performance of those bezier curves, wasn't it? It's just a lot of scary symbols but once you know what they mean it's actually surprisingly easy to use.
And it's also useful, you can get a better look into performance. Again if you have two