프로젝트 오일러 019

20세기에서 매월 1일이 일요일인 경우는 몇 번? 윤년과 날짜 다루는 법

프로젝트 오일러 019
Photo by Aleks Marinkovic / Unsplash

문제

19번 문제
20세기에서, 매월 1일이 일요일인 경우는 몇 번?

날짜, 시간과 달력

문제에서는 달력에 관한 몇 가지 일반적인 정보를 알려주고 있으며, 필요한 경우에는 좀 더 연구를 해보라고 권하고 있습니다.

20세기에서 일과 요일이 각각 1이면서 일요일인 경우를 세는 문제입니다. 따라서 연도, 월, 일자, 요일의 정보를 날짜에 맞게 증가시켜가면서 세어 보겠습니다.

  1. 우리가 알고 있는 힌트는 1900년 1월 1일이 월요일이라는 것입니다. 1901년 이후부터 카운트해야 하므로 실제로 체크하는 날짜는 1900년 1월 1일 ~ 2000년 12월 1일까지입니다.
  2. 편의상 요일은 일요일이면 0, 월요일이면 1, ... , 토요일이면 6으로합니다.

사실 컴퓨터에서 날짜나 시간을 다루는 일은 제법 까다롭습니다. 날짜와 시간은 연속적으로 증가하지만, 어떤 시점에 따라서 그 양을 표기하는 방식이 달라지는 셈이고, 그 방법을 결정하는 것이 달력입니다. 보통 컴퓨터는 날짜와 시간을 과거 특정 지점을 기준으로 그 시점으로부터 경과한 초 혹은 밀리초를 가지고 특정한 시점음 정의합니다. 따라서 1730557687.631202 라는 값을 2024년 11월 2일로 표현할수도 있고, 2024년 10월 2일로 표현할 수도 있고 혹은 4357년 11월 2일로 표현할 수도 있습니다. 이는 어느 달력을 기준으로 날짜를 표현하는 가에 달려 있습니다.

문제에서 사용하는 달력은 '그레고리력'입니다. 문제에서 설명하는 윤년의 계산 방법이 그레고리력에서 사용하는 것과 같은 방식입니다.

그 외에도 날짜는 보통 문자열로 표현하기 때문에, 날짜 값을 문자열로 변환하거나 반대로 문자열을 날짜값으로 변환할 수 있어야 합니다. 그런데 날짜를 문자열로 표현하는 방식(포맷)도 문화권마다 다릅니다. 이와 같이 날짜를 다루는 일에는 고려해야 하는 것들이 많은 관계로, 보통 대부분의 프로그래밍 언어들에서는 날짜와 시간을 다루는 도구들을 기본적으로 제공하는 편입니다.

윤년

날짜를 셀 때 빠질 수 없는 것이 윤년입니다. 윤년은 4년에 한 번 있지만, 매 100년은 윤년이 아니며, 매 400년은 다시 윤년입니다.

이 과정을 그대로 로직으로 옮기면 좀 복잡해보입니다.

def is_leap(y: int) -> bool:
  if y % 4 == 0:
    if y % 100 == 0:
      if y % 400 == 0:
        return True
      return False
    return True
  return False

무엇보다 조건식 다음에 True/False를 리턴하는 것이 마음에 들지 않습니다. 이건 마치 is_even()을 구현하면서 if문을 쓰는 거랑 똑같습니다.

# very bad
def is_even(n: int) -> bool:
  if n % 2 == 0:
    return True
  return Flase



# the right one
def is_even(n: int) -> bool:
  return n % 2 == 0

4, 100, 400은 모두 배수 관계이므로, 큰 수부터 검사한다면 아래와 같이 하나의 표현식으로 판정할 수 있습니다.

def is_leap(y: int) -> bool:
  return y % 400 == 0 or y % 100 > 0 and y % 4 ==0

풀이

파이썬으로 구현한 풀이는 다음과 같습니다.

def main():
    cnt = 0
    res = []
    is_leap = lambda y: y % 400 == 0 or y % 100 > 0 and y % 4 == 0
    y, m, d, w = (1900, 1, 1, 1)
    months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    while (y, m, d) < (2001, 1, 1):
        w = (w + 1) % 7
        d += 1
        if d > months[m - 1]:
            m, d = m + 1, 1
            if m > 12:
                y, m = y + 1, 1
                months[1] = 29 if is_leap(y) else 28
        if 1900 < y < 2001 and d == 1 and w == 0:
            cnt += 1
            res.append((y, m, d, w))
    print(cnt)


main()

datetime

날짜를 다루는 도구는 대부분의 언어들이 제공하고 있고, 파이썬도 예외는 아니기에 파이썬에서 날짜와 시간을 다루는 datetime 모듈을 이용한 풀이도 소개합니다.

from datetime import datetime, timedelta

cnt = 0
t = datetime(1901, 1, 1)
d = timedelta(days=1)
while t.year < 2001:
  if t.day == 1 and t.weekday() == 6:
    cnt += 1
  t += d
print(cnt)

datetime에서 제일 이상한 부분은 요일의 순번입니다. 월요일을 0으로 해둬서 일요일이 6이에요. 이 부분은 언어마다 달라서 대환장 파티입니다.

우선 ISO 8601 표준에는 한 주의 시작을 월요일로, 월요일은 1, 일요일은 7로 표현합니다. 파이썬은 월요일부터 시작은 하지만, 0-베이스로 월요일은 0, 일요일은 6으로 표현합니다. 아, 이런 불편함 때문에 isoweekday()라는 메소드가 추가돼서 표준 요일 번호 체계에 대응은 할 수 있습니다.

Julia, Rust, Swift 등의 최근에 개발된 언어들은 대부분 ISO 8601을 준수합니다. 그 외에 Java, Javascript같은 경우는 일반적인 서양권 문화에 맞춰서 일요일=0, 토요일=6인 요일 번호를 체계를 씁니다.