Project Euler 001 The Hard Way
This is a rewrite of an article I wrote in January 2012 where I discover the hidden complexity of a simple programming problem.
Problem 001
On the popular programming challenge website Project Euler, the first problem seems quite trivial. However, with deeper examination we can discover layers of complexity as we try to generalize the problem.
If we list all the natural numbers below 10 that are multiples of 3 or 5, we get 3, 5, 6 and 9. The sum of these multiples is 23. Find the sum of all the multiples of 3 or 5 below 1000.
It looks almost like a generic screening interview problem. The problem should be solvable anywhere between 1 and 5 minutes by any developer. It's a simple loop, some logic, and addition.
Solving every set of multiples
Looking at the code it's quite obvious 3 and 5 are replaceable with any set of other numbers. All we need to do is tweak the code to support accepting an array of multiples that will be tested.
Fixing the performance issue
Every time it searches for a higher number it ends up taking longer to run. So how else might I try to solve this? I recalled a story a high school math teacher told me about the Mathematician Gauss. His teacher punished him with the daunting task of adding together all the numbers between 1 and 100. Gauss solved it in minutes, because saw a simple pattern which he invented called the Arithmetic Sum.
The formula for an arithmetic series is:
Where:
- is the number of terms
- is the first term
- is the last term
The sum is equal to the first and last number in the series, multiplied by half the count of numbers in the series.
What's amazing about this is it scales at O(1), there's no Ifs or Loops here. It works the same way for 100 as it does 10e100.
If we find the sum of 3s, we just find the number of times 999 can be divided by 3, then apply the arithmetic series formula. The same for 5s. This does however introduce a slight problem: just adding the two values together is higher than the final result. We need to subtract all multiples that are common to both 3 and 5 (to avoid double-counting).
So, in summary, find the sum of 3s, 5s, and subtract once the sum of 15s (since 15 is the LCM - Least Common Multiple - of 3 and 5).
Solving all combinations with performance
Thinking back to set theory and Venn diagrams, there's a visual intersection between 3s and 5s that is clearly just the set of 15s that need to be negated once. In more generic terms, if provided A and B, the script must run sum(A)+sum(B)-sum(AB).
Using two for-loops this is an easy task, but again leads to a slow running solution. The real problem here is finding a way to generate all permutations for a set. There's a great way to do that: simply counting in boolean and assigning a place-value to each element in the list of multiples.
That sounds a bit confusing so visualizing here will help. Let's look at this set of multiples with 3s, 5s, and 7s.
So there's some overlap between all these sets:
- 3s and 5s
- 5s and 7s
- 7s and 3s
- 3s, 5s, and 7s combined
So in this case I'd have to: sum(3)+sum(5)+sum(7)-sum(3×5)-sum(3×7)-sum(7×3)+sum(3×5×7).
As mentioned above, another way to look at this is binary mapping:
001 = C (cardinality 1: ADD)
010 = B (cardinality 1: ADD)
011 = BC (cardinality 2: SUBTRACT)
100 = A (cardinality 1: ADD)
101 = AC (cardinality 2: SUBTRACT)
110 = AB (cardinality 2: SUBTRACT)
111 = ABC (cardinality 3: ADD)
Simply knowing this mapping we can generate all the combinations of sums needed. If it's added or subtracted is simply a matter of how many 1s are in a number: odd numbers get added, even gets removed.
What's exciting here is using bitwise operators to generate all possible combinations. Each number from 1 to 2^n-1 represents a unique subset when interpreted as a binary mask!
This follows the inclusion-exclusion principle, which can be expressed mathematically as:
For our specific case with three sets (multiples of 3, 5, and 7), this becomes:
So with this known, we simply need a combination generator. This will take ["a", "b", "c" , "d"] and return ["a", "b", "ab", "c", "ac", "bc", "abc", "d", "ad", "bd", "abd", "cd", "acd", "bcd", "abcd"] (all combinations of ABCD).
Now combining everything together the result is something like this:
Results
A quick check of performance yields these results:
- Using really large numbers, the math approach is much faster.
- Using really small sets, the simple for-loop is faster in 2020, but I remember it was slower in 2012 when I originally tested this.
The mathematical approach scales at O(1) regardless of range size - it works the same for 1,000 as it does for 10^100. The brute force approach scales linearly with the range size.
