Dates, triangles, and Python
December 1, 2023 at 12:31 PM by Dr. Drang
Tuesday morning, which was November 28, John D. Cook started a post with
The numbers in today’s date—11, 28, and 23—make up the sides of a triangle. This doesn’t always happen; the two smaller numbers have to add up to more than the larger number.
He went on to figure out the angles of the plane triangle with side lengths of 11, 28, and 23 and then extended his analysis to triangles on a sphere and a pseudosphere. But I got hung up on the quoted paragraph. Which days can and can’t be the sides of a triangle? And how does the number of such “triangle days” change from year to year?
So I wrote a little Python to answer these questions.
python:
1: from datetime import date, timedelta
2:
3: def isTriangleDay(dt):
4: "Can the the year, month, and day of the given date be the sides of a triangle?"
5: y = dt.year % 100
6: m = dt.month
7: d = dt.day
8: sides = sorted(int(x) for x in (y, m, d))
9: return sides[0] + sides[1] > sides[2]
10:
11: def allDays(y):
12: "Return a list of all days in the given year."
13: start = date(y, 1, 1)
14: end = date(y, 12, 31)
15: numDays = (end - start).days + 1
16: return [ start + timedelta(days=n) for n in range(numDays) ]
17:
18: def triangleDays(y):
19: "Return a list of all the triangle days in the given year."
20: return [x for x in allDays(y) if isTriangleDay(x) ]
isTriangleDay
is a Boolean function that implements the test Cook described for a datetime.date
object. Note that Line 5 extracts just the last two digits of the year, which is what Cook intends. You could, I suppose, change Line 9 to
python:
9: return sides[0] + sides[1] >= sides[2]
if you want to accept degenerate triangles, where the three sides collapse onto a single line. I don’t.
The allDays
function uses a list comprehension to return a list of all the days in a given year, and triangleDays
calls isTriangleDay
to filter the results of allDays
down to just triangle days. I think both of these functions are self-explanatory.
With these functions defined, I got all the triangle days for 2023 via
python:
print('\n'.join(x.strftime('%Y-%m-%d') for x in triangleDays(2023)))
which returned this list of dates (after reshaping into four columns):
2023-01-23 2023-06-27 2023-09-19 2023-11-17
2023-02-22 2023-06-28 2023-09-20 2023-11-18
2023-02-23 2023-07-17 2023-09-21 2023-11-19
2023-02-24 2023-07-18 2023-09-22 2023-11-20
2023-03-21 2023-07-19 2023-09-23 2023-11-21
2023-03-22 2023-07-20 2023-09-24 2023-11-22
2023-03-23 2023-07-21 2023-09-25 2023-11-23
2023-03-24 2023-07-22 2023-09-26 2023-11-24
2023-03-25 2023-07-23 2023-09-27 2023-11-25
2023-04-20 2023-07-24 2023-09-28 2023-11-26
2023-04-21 2023-07-25 2023-09-29 2023-11-27
2023-04-22 2023-07-26 2023-09-30 2023-11-28
2023-04-23 2023-07-27 2023-10-14 2023-11-29
2023-04-24 2023-07-28 2023-10-15 2023-11-30
2023-04-25 2023-07-29 2023-10-16 2023-12-12
2023-04-26 2023-08-16 2023-10-17 2023-12-13
2023-05-19 2023-08-17 2023-10-18 2023-12-14
2023-05-20 2023-08-18 2023-10-19 2023-12-15
2023-05-21 2023-08-19 2023-10-20 2023-12-16
2023-05-22 2023-08-20 2023-10-21 2023-12-17
2023-05-23 2023-08-21 2023-10-22 2023-12-18
2023-05-24 2023-08-22 2023-10-23 2023-12-19
2023-05-25 2023-08-23 2023-10-24 2023-12-20
2023-05-26 2023-08-24 2023-10-25 2023-12-21
2023-05-27 2023-08-25 2023-10-26 2023-12-22
2023-06-18 2023-08-26 2023-10-27 2023-12-23
2023-06-19 2023-08-27 2023-10-28 2023-12-24
2023-06-20 2023-08-28 2023-10-29 2023-12-25
2023-06-21 2023-08-29 2023-10-30 2023-12-26
2023-06-22 2023-08-30 2023-10-31 2023-12-27
2023-06-23 2023-09-15 2023-11-13 2023-12-28
2023-06-24 2023-09-16 2023-11-14 2023-12-29
2023-06-25 2023-09-17 2023-11-15 2023-12-30
2023-06-26 2023-09-18 2023-11-16 2023-12-31
That’s 136 triangle days for this year. To see how this count changes from year to year, I ran
python:
for y in range(2000, 2051):
print(f'{y} {len(triangleDays(y)):3d}')
which returned
2000 0
2001 12
2002 34
2003 54
2004 72
2005 88
2006 102
2007 114
2008 124
2009 132
2010 138
2011 142
2012 144
2013 144
2014 144
2015 144
2016 144
2017 144
2018 144
2019 144
2020 144
2021 142
2022 140
2023 136
2024 132
2025 127
2026 120
2027 113
2028 104
2029 93
2030 82
2031 72
2032 61
2033 51
2034 41
2035 33
2036 25
2037 19
2038 13
2039 8
2040 5
2041 2
2042 1
2043 0
2044 0
2045 0
2046 0
2047 0
2048 0
2049 0
2050 0
I knew there was no point in checking on years later in the century—it was obvious that every year after 2042 would have no triangle days. As you can see, the 2010s were the peak decade for triangle days. We’re now in the early stages of a 20-year decline.
After doing this, I looked back at my code and decided that most serious Python programmers wouldn’t have done it the way I did. Instead of functions that returned lists, they would build allDays
and triangleDays
as iterators.1 Not because there’s any need to save space—the space used by 366 datetime.date
objects is hardly even noticeable—but because that’s more the current style.
So to make myself feel more like a real Pythonista, I rewrote the code like this:
python:
1: from datetime import date, timedelta
2:
3: def isTriangleDay(dt):
4: "Can the the year, month, and day of the given date be the sides of a triangle?"
5: y = dt.year % 100
6: m = dt.month
7: d = dt.day
8: sides = sorted(int(x) for x in (y, m, d))
9: return sides[0] + sides[1] > sides[2]
10:
11: def allDays(y):
12: "Iterator for all days in the given year."
13: d = date(y, 1, 1)
14: end = date(y, 12, 31)
15: while d <= end:
16: yield d
17: d = d + timedelta(days=1)
18:
19: def triangleDays(y):
20: "Iterator for all the triangle days in the given year."
21: return filter(isTriangleDay, allDays(y))
isTriangleDay
is unchanged, but allDays
now works its way through the days of the year with a while
loop and the yield
statement, and triangleDays
uses the filter
function to iterate through just the triangle days.
Using these functions is basically the same as using the list-based versions, except that you can’t pass an iterator to len
. So determining the number of triangle days over a range of years can be done by either by converting the iterator to a list before passing it to len
,
python:
for y in range(2000, 2051):
print(f'{y} {len(list(triangleDays(y))):3d}')
or by using the sum
command with an argument that produces a one for each element of triangleDays
,
python:
for y in range(2000, 2051):
print(f'{y} {sum(1 for x in triangleDays(y))}')
The former sort of defeats the purpose of using an iterator, so I guess it’s better practice to use the latter, even though I find it weird looking.
It may well be that my perception of “real” Python programmers is wrong and they wouldn’t bother with yield
and filter
in such a piddly little problem as this. But at least I got some practice with them.
-
A confession: I find it hard to distinguish between between the proper use of the terms generator and iterator. My sense is that generators provide a way of creating iterators. So once the function is written, do you have a generator, an iterator, or both? ↩