Skip to content

Commit 6f1fa93

Browse files
colinleachBethanyG
andauthored
[Pythagorean Triplet]: Approaches Draft (exercism#3582)
* [Pythagorean Triplet]: Approaches Draft * [Leap]: Add `isleap` approach and improved benchmarks * Delete exercises/practice/leap/.articles/performance/timeit_bar_plot.svg Sorry, I committed this to the wrong branch. Embarrassingly amateurish! * Suggestions, Edits, etc. Suggestions and edits. Because we need to PR to a different repo, the `.svg` images have been removed, and the text adjusted. --------- Co-authored-by: BethanyG <BethanyG@users.noreply.github.com>
1 parent e1879a3 commit 6f1fa93

16 files changed

Lines changed: 705 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"introduction": {
3+
"authors": ["colinleach",
4+
"BethanyG"],
5+
"contributors": []
6+
},
7+
"approaches": [
8+
{
9+
"uuid": "4d4b4e6c-a026-4ed3-8e07-6b9edfabe713",
10+
"slug": "cubic",
11+
"title": "Cubic",
12+
"blurb": "Cubic-time approach with loops nested 3 deep.",
13+
"authors": ["colinleach",
14+
"BethanyG"]
15+
},
16+
{
17+
"uuid": "385c0ace-117f-4480-8dfc-0632d8893e60",
18+
"slug": "quadratic",
19+
"title": "Quadratic",
20+
"blurb": "Quadratic-time approaches with doubly-nested loops.",
21+
"authors": ["colinleach",
22+
"BethanyG"]
23+
},
24+
{
25+
"uuid": "1addb672-6064-4a07-acad-4a08f92d9e43",
26+
"slug": "linear",
27+
"title": "Linear Loop",
28+
"blurb": "Linear-time approaches with no nesting of loops.",
29+
"authors": ["colinleach",
30+
"BethanyG"]
31+
}
32+
]
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Cubic-time triply-nested loops
2+
3+
```python
4+
def triplets_with_sum(n):
5+
triplets = []
6+
for a in range(1, n + 1):
7+
for b in range(a + 1, n + 1):
8+
for c in range(b + 1, n + 1):
9+
if a**2 + b**2 == c**2 and a + b + c == n:
10+
triplets.append([a, b, c])
11+
return triplets
12+
```
13+
14+
The strategy in this case is to scan through all three variables and test them in the innermost loop.
15+
But the innermost loop will test all variables _every time_ the enclosing loop iterates.
16+
And the enclosing loop up will iterate through all of its range _every time_ the outermost loop iterates.
17+
18+
So the 'work' this code has to do for each additional value in `range(1, n+1)` is `n**3`.
19+
20+
This gives code that is simple, clear, ...and so slow as to be useless for all but the smallest values of `n`.
21+
22+
We could tighten up the bounds on loop variables: for example, `a` is the smallest integer of a triplet that sums to `n`, so inevitably `a < n //3`.
23+
24+
However, this is not nearly enough to rescue an inappropriate algorithm.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def triplets_with_sum(n):
2+
triplets = []
3+
for a in range(1, n + 1):
4+
for b in range(a + 1, n + 1):
5+
for c in range(b + 1, n + 1):
6+
if a**2 + b**2 == c**2 and a + b + c == n:
7+
triplets.append([a, b, c])
8+
return triplets
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Introduction
2+
3+
The main challenge in solving the Pythagorean Triplet exercise is to come up with a 'fast enough' algorithm.
4+
The problem space can easily become very large, and 'naive' or more 'brute force' solutions may time out on the test runner.
5+
6+
There are three reasonably common variations to this problem
7+
1. A [cubic time][approaches-cubic] solution, which uses highly nested loops and is non-performant.
8+
2. A [quadratic time][approaches-quadratic] solution, which uses one nested loop, and is reasonably easy to figure out.
9+
3. A [linear time][approaches-linear] solution, requiring some deeper understanding of the mathematics of finding trplets.
10+
11+
12+
If those terms are unclear to you, you might like to read about [time complexity][time-complexity], and how it is described by [asymptotic notation][asymptotic-notation].
13+
14+
The basic idea is to study how algorithms scale (_in CPU/GPU run time, memory usage, or other resource_) as the input parameters grow toward infinity.
15+
In this document we will focus on run time, which is critical for this exercise.
16+
17+
18+
## General guidance
19+
20+
The goal of `Pythagorean Triplets` is to find combinations of three numbers `[a, b, c]` satisfying a set of conditions:
21+
22+
1. `a < b < c`
23+
2. `a**2 + b**2 == c**2` (_otherwise known as the [Pythagorean theorem][Pythagorean-theorem]_)
24+
3. `a + b + c == n`, where `n` is supplied as a parameter to the function.
25+
26+
For a given `n`, the solution should include all valid triplets as a list of lists.
27+
28+
29+
## Approach: Cubic time solution with 3-deep nested loops
30+
31+
```python
32+
def triplets_with_sum(n):
33+
triplets = []
34+
for a in range(1, n + 1):
35+
for b in range(a + 1, n + 1):
36+
for c in range(b + 1, n + 1):
37+
if a**2 + b**2 == c**2 and a + b + c == n:
38+
triplets.append([a, b, c])
39+
return triplets
40+
```
41+
42+
This is the most 'naive' or 'brute force' approach, scanning through all possible integers `<= n` that satisfy `a < b < c`.
43+
This might be the first thing you think of when sketching out the algorithm on paper following the exercise instructions.
44+
It is useful to see the steps of the solution and to look at the size of the problem space.
45+
46+
***Don't implement this in code!***
47+
48+
While it is a valid solution and it indeed works for small values of `n`, it becomes impossibly slow as `n` grows larger.
49+
For any truly large values of `n`, this code might take over all the available processing power on your local computer and never complete.
50+
For Exercism's online environment, the test suite will time out and fail.
51+
52+
53+
## Approach: Quadratic time solution with 2-deep nested loops
54+
55+
```python
56+
def triplets_with_sum(n):
57+
triplets = []
58+
for a in range(1, n + 1):
59+
for b in range(a + 1, n + 1):
60+
c = n - a - b
61+
if a ** 2 + b ** 2 == c ** 2:
62+
triplets.append([a, b, c])
63+
return triplets
64+
```
65+
66+
Given the constraint that `a + b + c == n`, we can eliminate the innermost loop from the cubic approach and calculate `c` directly.
67+
This gives a substantial speed advantage, allowing the tests to run to completion in a reasonable time, _locally_.
68+
69+
However, the Exercism online test runner will still time out with this solution.
70+
71+
Examining the code, it is clear that the upper bounds on the `loop` variables are far too generous, and too much work is bing done.
72+
73+
74+
The solution below tightens the bounds and pre-calculates `c * c` in the outer `loop`.
75+
This gives about a 4-fold speedup, but still times out on the online test runner:
76+
77+
78+
```python
79+
def triplets_with_sum(n):
80+
result = []
81+
for c in range(5, n - 1):
82+
c_sq = c * c
83+
for a in range(3, (n - c + 1) // 2):
84+
b = n - a - c
85+
if a < b < c and a * a + b * b == c_sq:
86+
result.append([a, b, c])
87+
return result
88+
```
89+
90+
If a quadratic-time algorithm was the best available option, there are other ways to squeeze out small performance gains.
91+
92+
For bigger problems outside Exercism, there are third-party packages such as [`numpy`][numpy] or [`numba`][numba] which replace Python
93+
loops (_versatile but relatively slow_) with routines written in C/C++, perhaps with use of the GPU.
94+
The runtime is still proportional to `n**2`, but the proportionality constant (_which would be measured in C/C++ as opposed to Python_) may be much smaller.
95+
96+
Fortunately for the present discussion, mathematicians have been studying Pythagorean Triplets for centuries: see [Wikipedia][wiki-pythag], [Wolfram MathWorld][wolfram-pythag], or many other sources.
97+
98+
So mathematically there are much faster algorithms, at the expense of reduced readability.
99+
100+
101+
## Linear time solutions
102+
103+
```python
104+
from math import sqrt
105+
106+
def triplets_with_sum(n):
107+
N = float(n)
108+
triplets = []
109+
for c in range(int(N / 2) - 1, int((sqrt(2) - 1) * N), -1):
110+
D = sqrt(c ** 2 - N ** 2 + 2 * N * c)
111+
if D == int(D):
112+
triplets.append([int((N - c - D) / 2), int((N - c + D) / 2), c])
113+
return triplets
114+
```
115+
116+
_All clear?_ 😉
117+
118+
After some thoughtful mathematical analysis, there is now only a single loop!
119+
120+
Run time is now much faster, especially for large `n`, but a reasonable person could find it quite difficult to understand what the code is doing.
121+
122+
If you do things like this out in the 'real world' ***please*** document your code carefully.
123+
It might also be helpful to choose variable names that are more descriptive to help readers understand all of the values and operations.
124+
In a few weeks, the bare code will puzzle your future self.
125+
People reading it for the first time are likely to struggle even more than you will.
126+
127+
The code above uses fairly 'generic' programming syntax.
128+
Another submission used the same basic algorithm but in a more 'Pythonic' way:
129+
130+
131+
```python
132+
def triplets_with_sum(n):
133+
def calculate_medium(small):
134+
return (n ** 2 - 2 * n * small) / (2 * (n - small))
135+
136+
two_sides = ((int(medium), small) for small in range(3, n // 3)
137+
if small < (medium := calculate_medium(small))
138+
and medium.is_integer())
139+
140+
return [[small, medium, (medium ** 2 + small ** 2) ** 0.5]
141+
for medium, small in two_sides]
142+
```
143+
144+
Although it is important to note that this solution could have chosen a better name for the `n` parameter, and clearer formatting for the `generator-expression` and the `list-comprehension`:
145+
146+
```python
147+
def triplets_with_sum(number):
148+
def calculate_medium(small):
149+
150+
# We have two numbers, but need the third.
151+
return (number ** 2 - 2 * number * small) / (2 * (number - small))
152+
153+
two_sides = (
154+
(int(medium), small) for
155+
small in range(3, number // 3) if
156+
157+
#Calls calculate_medium and assigns return value to variable medium
158+
small < (medium := calculate_medium(small)) and
159+
medium.is_integer()
160+
)
161+
162+
return [
163+
[small, medium, (medium ** 2 + small ** 2) ** 0.5]
164+
for medium, small in two_sides
165+
]
166+
```
167+
168+
169+
## Which approach to use?
170+
171+
If we could be sure that the code only had to handle small values of `n`, a quadratic method would have the advantage of clarity.
172+
173+
However, the test suite goes up to 30_000, and the online test runner quickly times out.
174+
We therefor need to accept some less readable code and use a linear-time implementation.
175+
176+
Full details of run-time benchmarking are given in the [Performance article][article-performance].
177+
178+
Overall, the results confirm the expectation that the linear-time methods are _very much_ faster.
179+
More surprisingly, the first example of the linear implementation (_with an explicit loop_) proved slightly faster than the more 'Pythonic' code.
180+
This is likely due to the overhead of creating and tracking the iterator for the `generator-expression`, calculating the 'expensive' `calculate_medium()` function call within that generator, and the additional 'expensive' conversions to `int()`.
181+
182+
[Pythagorean-theorem]: https://en.wikipedia.org/wiki/Pythagorean_theorem
183+
[approaches-cubic]: https://exercism.org/tracks/python/exercises/pythagorean-triplet/approaches/cubic
184+
[approaches-linear]: https://exercism.org/tracks/python/exercises/pythagorean-triplet/approaches/linear
185+
[approaches-quadratic]: https://exercism.org/tracks/python/exercises/pythagorean-triplet/approaches/quadratic
186+
[article-performance]:https://exercism.org/tracks/python/exercises/pythagorean-triplet/articles/performance
187+
[asymptotic-notation]: https://www.khanacademy.org/computing/computer-science/algorithms/asymptotic-notation/a/asymptotic-notation
188+
[numba]: https://numba.pydata.org/
189+
[numpy]: https://numpy.org/
190+
[time-complexity]: https://yourbasic.org/algorithms/time-complexity-explained/
191+
[wiki-pythag]: https://en.wikipedia.org/wiki/Pythagorean_triple
192+
[wolfram-pythag]: https://mathworld.wolfram.com/PythagoreanTriple.html
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Linear-time algorithms with no nested loops
2+
3+
4+
The key point with this approach is that we only loop over the variable `c` in a specific range.
5+
Some mathematical analysis (_essentially, [solving simultaneous equations][simultaneous-equasions]_) then allows us to find valid values of `a` and `b`.
6+
7+
Other than that, the code syntax below is fairly mainstream across programming languages.
8+
A related approach instead loops over `a`.
9+
10+
```python
11+
from math import sqrt
12+
13+
def triplets_with_sum(n):
14+
N = float(n)
15+
triplets = []
16+
for c in range(int(N / 2) - 1, int((sqrt(2) - 1) * N), -1):
17+
D = sqrt(c ** 2 - N ** 2 + 2 * N * c)
18+
if D == int(D):
19+
triplets.append([int((N - c - D) / 2), int((N - c + D) / 2), c])
20+
return triplets
21+
```
22+
23+
24+
This second code example has no explicit `for` loop (_in Python syntax_), but the `generator-expression` and the `list-comprehension` both translate to `FOR_ITER` at the bytecode level.
25+
So this solution is essentially the same as the first, written in a more 'Pythonic' syntax -- but that syntax does incur a small overhead in performance.
26+
The performance hit is likely due to the extra instructions in the bytecode used to manage the `generator-expression` (_pausing the loop, resuming the loop, yielding results_) and then calling or unpacking the generator in the `list comprehension`.
27+
However, you would have to carefully profile the code to really determine the slowdown.
28+
29+
With all that said, using a `generator` or `generator-expression` with or without a `list-comprehension` might be a better strategy if your code needs to process a very large number of triplets, as it avoids storing all the results in memory until they need to be returned.
30+
Using a `generator` or `generator-expression` by itself can also nicely set up a scenario where results are "streamed" or emitted 'on demand' for another part of the program or application.
31+
32+
For more details on what these two solutions look like at the byte code level, take a look at Pythons [`dis`][dis] module.
33+
34+
35+
```python
36+
def triplets_with_sum(n):
37+
def calculate_medium(small):
38+
return (n ** 2 - 2 * n * small) / (2 * (n - small))
39+
40+
two_sides = ((int(medium), small) for small in range(3, n // 3)
41+
if small < (medium := calculate_medium(small))
42+
and medium.is_integer())
43+
44+
return [[small, medium, (medium ** 2 + small ** 2) ** 0.5]
45+
for medium, small in two_sides]
46+
```
47+
48+
49+
Some implementation details to notice:
50+
- Nested functions, with the inner function able to reference variables such as `n` passed into the outer function.
51+
- The generator expression creates `two_sides` as a lazily-evaluated iterator (_smaller memory footprint_)
52+
- The [`walrus operator`][walrus-operator] `:=` is new in Python 3.8.
53+
- The `is_integer()` method replaces `if D == int(D)`.
54+
- Using `** 0.5` to calculate the square roots avoids a `math` import.
55+
56+
57+
[dis]: https://docs.python.org/3/library/dis.html
58+
[simultaneous-equasions]: https://thirdspacelearning.com/gcse-maths/algebra/simultaneous-equations/
59+
[walrus-operator]: https://mathspp.com/blog/pydonts/assignment-expressions-and-the-walrus-operator
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def triplets_with_sum(n):
2+
def calculate_medium(small):
3+
return (n ** 2 - 2 * n * small) / (2 * (n - small))
4+
two_sides = ((int(medium), small) for small in range(3, n // 3)
5+
if small < (medium := calculate_medium(small))
6+
and medium.is_integer())
7+
return [[small, medium, (medium ** 2 + small ** 2) ** 0.5]
8+
for medium, small in two_sides]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Quadratic-time doubly-nested loops
2+
3+
```python
4+
def triplets_with_sum(n):
5+
triplets = []
6+
for a in range(1, n + 1):
7+
for b in range(a + 1, n + 1):
8+
c = n - a - b
9+
if a ** 2 + b ** 2 == c ** 2:
10+
triplets.append([a, b, c])
11+
return triplets
12+
```
13+
14+
Because `a + b + c == n`, we only loop over `a` and `b`.
15+
The third variable `c` is then predictable.
16+
17+
The above code loops over the full range of both variables.
18+
This means the 'work' this code has to do for each additional value in `range(1, n+1)` is `n**2`.
19+
We know enough about the problems to tighten this up a bit.
20+
21+
For example:
22+
- The smallest pythagorean is (famously) `[3, 4, 5]`, so `a >= 3`
23+
- `a + b == n - c` and `a <= b`, so `a <= (n - c) // 2`
24+
25+
We can also avoid, to some extent, repeating the same multiplication many times.
26+
This gets us to the code below:
27+
28+
29+
```python
30+
def triplets_with_sum(n):
31+
result = []
32+
for c in range(5, n - 1):
33+
c_sq = c * c
34+
for a in range(3, (n - c + 1) // 2):
35+
b = n - a - c
36+
if a < b < c and a * a + b * b == c_sq:
37+
result.append([a, b, c])
38+
return result
39+
```
40+
41+
We could have done a bit better.
42+
Though not stated in the problem description, `a + b > c`, otherwise they could not form a triangle.
43+
44+
This means that `c <= n // 2`, reducing the iterations in the outer loop.
45+
46+
However, these optimizations only reduce the run time by a small factor.
47+
They do almost nothing to make the algorithm scale to large `n`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def triplets_with_sum(n):
2+
triplets = []
3+
for a in range(1, n + 1):
4+
for b in range(a + 1, n + 1):
5+
c = n - a - b
6+
if a ** 2 + b ** 2 == c ** 2:
7+
triplets.append([a, b, c])
8+
return triplets

0 commit comments

Comments
 (0)