Project Euler 2: 짝수 피보나치 수만 골라 더하기

피보나치 수열에서 짝수 항이 세 칸마다 나온다는 사실을 증명하고, 짝수 항끼리의 점화식 E(k) = 4E(k−1) + E(k−2)를 유도해 사백만 이하의 짝수 항만 더합니다. C++17과 Python 3 구현, 정확성 증명, 정수 범위까지 다룹니다.

Project Euler알고리즘수학피보나치

스포일러 주의 · 이 글에는 풀이 과정과 정답이 그대로 나옵니다. 직접 먼저 풀어 보고 싶다면 Project Euler 2번 문제부터 보세요.

Project Euler 시리즈의 두 번째 문제입니다. 피보나치 수열을 차례로 만들면서 짝수만 더해도 풀리지만, 짝수 항이 어디에 나오는지를 먼저 따져 보면 짝수 항만 곧바로 이어 만드는 점화식을 얻을 수 있습니다. 홀짝(2로 나눈 나머지)을 따라가는 방법과 점화식을 변형하는 방법은 뒤의 문제에서도 자주 쓰입니다.

스포일러 주의: 이 글에는 풀이 전체와 정답이 들어 있습니다. 직접 먼저 풀어 보고 싶다면 원래 문제부터 보세요. 이 시리즈의 글에는 광고를 싣지 않습니다. 문제 본문의 라이선스(CC BY-NC-SA 4.0)가 비영리 조건을 두기 때문입니다.

문제

1과 2로 시작하고, 그다음 항부터는 바로 앞의 두 항을 더해 만드는 피보나치 수열을 생각합니다. 처음 몇 항은 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, …입니다. 이 가운데 값이 사백만(4,000,000)을 넘지 않는 항만 보고, 그중 짝수인 항을 모두 더한 값을 구합니다.

경계는 “넘지 않는다”, 즉 4,000,000 이하입니다. 4,000,000 자체는 피보나치 수가 아니어서 이 문제에서는 “미만”으로 읽어도 답이 같지만, 함수로 일반화할 때는 이하로 정확히 구현해 둡니다.

필요한 배경 지식

피보나치 수열과 번호 붙이기. 이 글에서는 문제의 시작값을 그대로 따라 F₁ = 1, F₂ = 2로 두고, n ≥ 3이면 Fₙ = Fₙ₋₁ + Fₙ₋₂로 정의합니다. 그러면 F₃ = 3, F₄ = 5, F₅ = 8, F₆ = 13, F₇ = 21, F₈ = 34입니다. 흔히 보는 1, 1, 2, 3, …과는 번호가 하나씩 어긋나므로, 다른 자료의 공식을 가져올 때는 번호를 맞춰야 합니다.

수열은 순증가합니다. F₂ > F₁이고, n ≥ 3이면 Fₙ = Fₙ₋₁ + Fₙ₋₂ > Fₙ₋₁입니다(모든 항이 양수이므로). 그래서 같은 값이 두 번 나오지 않고, 어떤 항이 상한을 넘으면 그 뒤의 항도 모두 넘습니다. “상한을 넘으면 멈춘다”는 반복이 옳은 근거가 이것입니다.

홀짝의 덧셈. 짝수 + 짝수 = 짝수, 짝수 + 홀수 = 홀수, 홀수 + 홀수 = 짝수입니다. 2로 나눈 나머지끼리 더한 뒤 다시 2로 나눈 나머지를 보면 되기 때문입니다(0 + 0 = 0, 0 + 1 = 1, 1 + 1 = 2 → 0). 피보나치 수열의 각 항은 앞의 두 항의 합이므로, 앞의 두 항의 홀짝만 알면 다음 항의 홀짝이 정해집니다.

지수적 증가. 항이 지수적으로 커지면 상한 N 아래의 항 수는 N이 아니라 log N에 비례합니다. 아래 “복잡도”에서 짝수 항에 대해 구체적인 부등식으로 보입니다.

가장 단순한 방법

가장 먼저 떠오르는 방법은 두 가지입니다.

  1. 1부터 4,000,000까지 모든 정수를 보면서 피보나치 수인지, 짝수인지 확인합니다. 정수를 하나씩 보므로 상한 N에 대해 O(N)번 이상의 확인이 필요하고, 피보나치 수인지 판정하는 비용도 따로 듭니다.
  2. 피보나치 수열을 직접 만들면서, 상한 이하인 항 중 짝수인 것만 더합니다.
a, b = 1, 2
total = 0
while a <= 4000000:
    if a % 2 == 0:
        total += a
    a, b = b, a + b

두 번째 방법은 이미 충분히 빠릅니다. 피보나치 수가 지수적으로 커지므로 4,000,000 이하의 항은 32개뿐이고, 반복도 32번이면 끝납니다. 시간 복잡도는 O(log N), 공간은 O(1)입니다. 그런데 이 32개 중 실제로 더하는 짝수 항은 11개이고, 나머지 21번은 홀짝을 확인하고 버리는 데 씁니다. 아래에서는 짝수 항이 정확히 어디에 나오는지 증명하고, 짝수 항만 곧바로 만드는 점화식을 유도합니다. 점근적인 복잡도는 같지만 반복 횟수가 약 3분의 1로 줄고 홀짝 검사가 사라지며, 무엇보다 “왜 그런지”를 식으로 설명할 수 있습니다.

풀이 유도

1단계: 작은 예로 패턴을 봅니다. 처음 열 항과 홀짝을 나란히 적어 봅니다.

n   :  1  2  3  4  5  6  7  8  9 10
Fₙ  :  1  2  3  5  8 13 21 34 55 89
홀짝:  홀 짝 홀 홀 짝 홀 홀 짝 홀 홀

짝수 항은 F₂ = 2, F₅ = 8, F₈ = 34로, 번호가 2, 5, 8처럼 세 칸마다 나옵니다. 89 이하의 짝수 항의 합은 2 + 8 + 34 = 44입니다. 이 값은 뒤에서 코드의 작은 예 검사에 씁니다.

2단계: 짝수 항이 정확히 세 칸마다 나온다는 것을 증명합니다. 연속한 두 항의 홀짝 쌍 (Fₙ, Fₙ₊₁)을 봅니다. 다음 쌍은 (Fₙ₊₁, Fₙ + Fₙ₊₁)이므로 지금 쌍만으로 정해집니다.

(홀, 짝) → (짝, 홀 + 짝 = 홀) → (홀, 짝 + 홀 = 홀) → (홀, 홀 + 홀 = 짝) → (홀, 짝) → …

첫 쌍 (F₁, F₂) = (1, 2)가 (홀, 짝)이고, 세 번 넘어가면 다시 (홀, 짝)으로 돌아옵니다. 다음 쌍이 지금 쌍에 의해서만 정해지므로 이 주기 3의 순환은 끝없이 반복됩니다. 따라서 Fₙ이 짝수인 것은 n을 3으로 나눈 나머지가 2일 때, 즉 n = 3k − 1 (k = 1, 2, 3, …)일 때뿐입니다. 이제 짝수 항을 Eₖ = F₃ₖ₋₁로 부르겠습니다. E₁ = F₂ = 2, E₂ = F₅ = 8, E₃ = F₈ = 34입니다.

3단계: 세 칸 떨어진 항 사이의 관계를 만듭니다. n ≥ 7이면 정의 Fₘ = Fₘ₋₁ + Fₘ₋₂를 m = n, n − 1, n − 2, n − 4에 쓸 수 있습니다(모두 3 이상). 한 단계씩 풀어 씁니다.

Fₙ = Fₙ₋₁ + Fₙ₋₂
   = (Fₙ₋₂ + Fₙ₋₃) + Fₙ₋₂              ← Fₙ₋₁을 풀어 씀
   = 2Fₙ₋₂ + Fₙ₋₃
   = 2(Fₙ₋₃ + Fₙ₋₄) + Fₙ₋₃             ← Fₙ₋₂를 풀어 씀
   = 3Fₙ₋₃ + 2Fₙ₋₄
   = 3Fₙ₋₃ + Fₙ₋₄ + (Fₙ₋₅ + Fₙ₋₆)       ← Fₙ₋₄ 하나를 풀어 씀
   = 3Fₙ₋₃ + (Fₙ₋₄ + Fₙ₋₅) + Fₙ₋₆
   = 3Fₙ₋₃ + Fₙ₋₃ + Fₙ₋₆               ← Fₙ₋₄ + Fₙ₋₅ = Fₙ₋₃
   = 4Fₙ₋₃ + Fₙ₋₆

마지막 줄의 Fₙ₋₄ + Fₙ₋₅ = Fₙ₋₃도 정의를 m = n − 3에 쓴 것이고, n ≥ 7이면 n − 3 ≥ 4라서 쓸 수 있습니다.

4단계: 짝수 항끼리의 점화식으로 바꿉니다. n = 3k − 1을 넣으면 n − 3 = 3(k − 1) − 1, n − 6 = 3(k − 2) − 1이므로

Eₖ = 4Eₖ₋₁ + Eₖ₋₂    (k ≥ 3, 이때 n = 3k − 1 ≥ 8 ≥ 7)

입니다. 손으로 검산하면 E₃ = 4 · 8 + 2 = 34, E₄ = 4 · 34 + 8 = 144이고, 실제로 F₁₁ = 144입니다(F₉ = 55, F₁₀ = 89, F₁₁ = 144).

5단계: 시작값을 정리합니다. 점화식은 k ≥ 3부터 증명했으므로 E₁ = 2, E₂ = 8을 시작값으로 두면 됩니다. 코드에서는 편의상 E₀ = 0을 하나 더 둡니다. 4 · E₁ + E₀ = 4 · 2 + 0 = 8 = E₂이므로, (E₀, E₁) = (0, 2)에서 출발해도 같은 식으로 E₂부터 올바르게 이어집니다. E₀은 더하지 않는 보조값일 뿐입니다.

최종 알고리즘

상한을 limit이라고 하면:

  1. previous = 0(E₀), current = 2(E₁), sum = 0으로 시작합니다.
  2. current ≤ limit인 동안 sum에 current를 더하고, (previous, current)를 (current, 4 · current + previous)로 바꿉니다.
  3. current > limit이 되면 sum을 돌려줍니다.
  4. 정답을 출력하기 전에 작은 상한에서 결과를 확인합니다. 1 → 0(짝수 항 없음), 2 → 2, 8 → 2 + 8 = 10(경계 값을 포함하는지 확인), 89 → 44(1단계의 예). 하나라도 다르면 0이 아닌 종료 코드로 끝냅니다.

C++ 구현에는 아주 큰 상한에서 다음 항을 계산하다 넘치지 않도록 하는 검사가 하나 더 있습니다. 아래 “정수 범위”에서 설명합니다.

정확성 증명

반복문이 j번째 검사(j = 1, 2, …)를 할 때 다음이 성립합니다(불변식).

  • previous = Eⱼ₋₁, current = Eⱼ
  • sum = E₁ + E₂ + … + Eⱼ₋₁

처음(j = 1)에는 previous = 0 = E₀, current = 2 = E₁, sum = 0(빈 합)이므로 성립합니다. current ≤ limit이면 sum에 Eⱼ를 더해 E₁ + … + Eⱼ가 되고, 새 current는 4Eⱼ + Eⱼ₋₁ = Eⱼ₊₁(j = 1이면 5단계, j ≥ 2이면 4단계의 점화식), 새 previous는 Eⱼ이므로 j + 1에서도 성립합니다.

E는 피보나치 수열의 일부이므로 순증가하고, 언젠가 반드시 limit을 넘습니다. 처음으로 Eⱼ > limit이 되는 순간 반복이 끝나고, 이때 sum = E₁ + … + Eⱼ₋₁입니다. 순증가이므로 E₁, …, Eⱼ₋₁은 모두 limit 이하이고 Eⱼ 이후는 모두 limit을 넘습니다. 2단계에서 짝수인 피보나치 항은 정확히 E₁, E₂, …임을 보였고, 수열이 순증가하므로 각 값은 한 번씩만 나옵니다. 따라서 sum은 limit 이하인 짝수 피보나치 항의 합과 정확히 같습니다.

복잡도

반복 횟수. k ≥ 3이면 Eₖ = 4Eₖ₋₁ + Eₖ₋₂ > 4Eₖ₋₁이고, E₂ = 8 = 4E₁이므로 k ≥ 2에서 Eₖ ≥ 4Eₖ₋₁입니다. 따라서 Eₖ ≥ 2 · 4^(k−1)이고, limit 이하인 짝수 항의 개수 K는 2 · 4^(K−1) ≤ limit에서 K ≤ 1 + log₄(limit / 2)입니다. limit = 4,000,000이면 log₄(2,000,000) ≈ 10.47이라 K ≤ 11이고, 실제로 11개입니다. 반복 횟수가 O(log limit)이고 한 번의 반복은 고정 폭 정수에서 상수 시간이므로 시간 복잡도는 O(log limit), 저장하는 값이 세 개뿐이므로 공간 복잡도는 O(1)입니다.

정수 범위. 이 문제의 값은 모두 작습니다. 더하는 마지막 짝수 항이 사백만 이하이고, 반복을 끝낼 때 만드는 다음 짝수 항도 1,500만 정도라 32비트 정수에도 들어갑니다. 그래도 함수를 다른 상한에 다시 쓸 수 있도록 범위를 따져 둡니다.

  • 합의 크기: 위의 Eⱼ₋₁ ≤ Eⱼ / 4를 거듭 쓰면, 마지막으로 더한 항을 E_K라 할 때 합은 E_K (1 + 1/4 + 1/16 + …) < (4/3) E_K ≤ (4/3) limit입니다. 그래서 합은 언제나 상한의 4/3배보다 작습니다.
  • C++는 unsigned long long을 씁니다. C++ 표준은 이 타입이 최소 64비트임을 보장하고(C 표준의 ULLONG_MAX 최솟값을 따름), 부호 없는 정수의 연산은 2^N을 법으로 정의되어 있어 정의되지 않은 동작이 생기지 않습니다. 다만 넘치면 조용히 틀린 값이 되므로 넘치지 않게 해야 합니다.
  • 다음 항 4 · current + previous는 상한이 2^64 가까이면 넘칠 수 있습니다. 넘쳐서 작은 값으로 돌아가면 반복이 끝나지 않거나 틀린 합을 더하게 됩니다. 그래서 C++ 구현은 다음 항을 계산하기 전에 current > (limit − previous) / 4인지 봅니다. 정수에서 4 · current > limit − previous는 current > ⌊(limit − previous) / 4⌋와 같으므로, 이 조건이 참이면 다음 항이 limit을 넘는다는 뜻이고 반복은 어차피 끝납니다. 계산하지 않고 멈춰도 결과는 같습니다. 반복 안에서는 previous ≤ current ≤ limit이라 limit − previous도 0 아래로 돌아가지 않습니다.
  • 이 검사 덕분에 다음 항은 limit 이하일 때만 계산되어 넘치지 않고, 합은 (4/3) limit보다 작으므로 limit ≤ 2^63이면 2^64보다 작습니다. 즉 C++ 함수는 limit ≤ 2^63에서 정확합니다.
  • Python의 int는 정밀도에 제한이 없어 넘칠 일이 없으므로 이 검사를 두지 않았습니다. 알고리즘은 같습니다.

경계 사례

  • limit < 2: 가장 작은 짝수 항 2보다 작으므로 짝수 항이 없고 결과는 0입니다. 반복 조건이 처음부터 거짓이라 따로 처리할 필요가 없습니다. Python에서는 0이나 음수를 넣어도 0이 됩니다. C++는 부호 없는 타입이라 음수를 받지 않습니다.
  • limit이 짝수 항과 같을 때: “넘지 않는다”는 이하이므로 그 항도 더해야 합니다. limit = 2이면 2, limit = 8이면 10이어야 하고, 코드의 작은 예 검사에 이 두 경우가 들어 있습니다.
  • 시작값이 1, 1일 때: 1, 1, 2, 3, …으로 시작하는 다른 정의에서는 1이 두 번 나오지만, 1은 홀수라 짝수 항의 합에는 영향이 없습니다. 이 문제에서 1과 2로 시작한다는 조건은 짝수 항의 합만 놓고 보면 결과를 바꾸지 않습니다.
  • 아주 큰 limit (C++): 위의 정수 범위대로 limit ≤ 2^63에서 정확합니다. 그보다 크면 합이 넘칠 수 있습니다.

C++17 구현

#include <iostream>

// Sum of the even Fibonacci terms (1, 2, 3, 5, 8, ...) that do not exceed limit.
// The even terms are 2, 8, 34, ..., and each one is 4 * previous + the one before.
unsigned long long sum_even_fibonacci_up_to(unsigned long long limit) {
    unsigned long long previous = 0;
    unsigned long long current = 2;
    unsigned long long sum = 0;
    while (current <= limit) {
        sum += current;
        // previous <= current <= limit, so limit - previous does not wrap.
        // If 4 * current + previous would exceed limit, stop before computing it.
        if (current > (limit - previous) / 4) {
            break;
        }
        const unsigned long long next = 4 * current + previous;
        previous = current;
        current = next;
    }
    return sum;
}

int main() {
    if (sum_even_fibonacci_up_to(1) != 0 || sum_even_fibonacci_up_to(2) != 2 ||
        sum_even_fibonacci_up_to(8) != 10 || sum_even_fibonacci_up_to(89) != 44) {
        return 1;
    }
    std::cout << sum_even_fibonacci_up_to(4000000) << '\n';
    return 0;
}

작은 예의 확인은 assert가 아니라 if로 했습니다. assert는 NDEBUG가 정의된 빌드에서 사라지기 때문입니다. 확인이 하나라도 틀리면 return 1로 0이 아닌 종료 코드를 냅니다.

Python 3 구현

def sum_even_fibonacci_up_to(limit: int) -> int:
    """Sum of the even Fibonacci terms (1, 2, 3, 5, 8, ...) that do not exceed limit.

    The even terms are 2, 8, 34, ..., and each one is 4 * previous + the one before.
    """
    previous, current = 0, 2
    total = 0
    while current <= limit:
        total += current
        previous, current = current, 4 * current + previous
    return total


def main() -> None:
    if (
        sum_even_fibonacci_up_to(1) != 0
        or sum_even_fibonacci_up_to(2) != 2
        or sum_even_fibonacci_up_to(8) != 10
        or sum_even_fibonacci_up_to(89) != 44
    ):
        raise SystemExit(1)
    print(sum_even_fibonacci_up_to(4000000))


if __name__ == "__main__":
    main()

C++와 같은 알고리즘, 같은 작은 예입니다. previous, current = current, 4 * current + previous는 오른쪽을 모두 계산한 뒤 한꺼번에 대입하므로 임시 변수가 필요 없습니다. 작은 예가 틀리면 raise SystemExit(1)로 종료 코드 1을 냅니다. assert는 python -O로 실행하면 제거되므로 쓰지 않았습니다.

정답과 검증

점화식으로 사백만 이하의 짝수 항을 차례로 만들면 다음과 같습니다(괄호 안은 그때까지의 합).

E₁  = 2                              (2)
E₂  = 8                              (10)
E₃  = 4 · 8      + 2      = 34       (44)
E₄  = 4 · 34     + 8      = 144      (188)
E₅  = 4 · 144    + 34     = 610      (798)
E₆  = 4 · 610    + 144    = 2584     (3382)
E₇  = 4 · 2584   + 610    = 10946    (14328)
E₈  = 4 · 10946  + 2584   = 46368    (60696)
E₉  = 4 · 46368  + 10946  = 196418   (257114)
E₁₀ = 4 · 196418 + 46368  = 832040   (1089154)
E₁₁ = 4 · 832040 + 196418 = 3524578
E₁₂ = 4 · 3524578 + 832040 = 14930352 > 4,000,000 → 멈춤

따라서 답은 1089154 + 3524578 = 4613732입니다.

검증은 세 겹으로 합니다.

  1. 작은 예. 두 프로그램 모두 답을 출력하기 전에 상한 1, 2, 8, 89의 결과가 0, 2, 10, 44인지 확인하고, 다르면 0이 아닌 종료 코드로 끝납니다. 89 이하의 값 44는 “풀이 유도” 1단계에서 처음 열 항으로 손으로도 확인했습니다.
  2. 두 구현의 일치. 이 시리즈의 저장소에서는 검사 스크립트가 C++ 코드를 g++ -std=c++17 -O2 -Wall -Wextra -Werror로 컴파일해 실행하고(경고도 실패로 처리), Python 코드를 python3 -I -B로 실행합니다. 두 출력이 저장해 둔 answer.txt와 바이트 단위로 같아야 하고, 0이 아닌 종료 코드나 시간 초과(C++ 1초, Python 10초)는 실패입니다. 시리즈 규칙상 이 검사를 통과한 코드만 글에 싣습니다.
  3. 손 계산과 다른 방법. 위 표에서 각 항이 실제 피보나치 수인지(예: 610 = F₁₄, 2584 = F₁₇) 두 항씩 더해 가며 맞춰 볼 수 있습니다. “가장 단순한 방법”의 반복문으로 32개 항을 모두 만들고 짝수만 더해 같은 값이 나오는지 비교해 보는 것도 좋은 연습입니다.

더 생각해 볼 것

  • 홀수 항의 합. 같은 상한 아래에서 홀수 항의 합은 어떻게 구할까요? 전체 항의 합에서 짝수 항의 합을 빼면 됩니다. 전체 합에는 F₁ + F₂ + … + Fₙ을 Fₙ₊₂로 나타내는 닫힌 식이 있으니, 이 글의 번호 붙이기(F₁ = 1, F₂ = 2)에 맞춰 직접 유도해 보세요.
  • 다른 나머지. 3의 배수인 피보나치 항은 몇 칸마다 나올까요? 2단계처럼 (Fₙ mod 3, Fₙ₊₁ mod 3) 쌍을 따라가면 주기를 찾을 수 있습니다. 그 항들 사이에도 3단계와 같은 방식으로 점화식을 만들 수 있는지 따져 보세요.
  • 세 칸 대신 m칸. 3단계의 전개를 일반화하면 Fₙ을 Fₙ₋ₘ과 Fₙ₋₂ₘ으로 나타낼 수 있습니다. m = 2일 때의 계수는 무엇이고, m = 3일 때 4가 나오는 이유는 무엇인지 확인해 보세요.
  • 오버플로 실험. C++ 코드에서 넘침 검사를 빼고 limit에 ULLONG_MAX에 가까운 값을 넣으면 어떤 일이 생기는지, 또 limit이 2^63보다 클 때 합이 실제로 넘치는지 Python의 결과와 비교해 보세요.
  • 이 시리즈의 다음 문제들. 피보나치 수와 점화식은 1–100번 안의 다른 문제에서도 다시 나옵니다. 그때는 항이 고정 폭 정수에 들어가지 않을 만큼 커지기도 하므로, 이번의 정수 범위 분석을 기억해 두면 도움이 됩니다.

출처와 라이선스

  • Project Euler, Problem 2 — 원래 문제. 이 글의 “문제” 절은 그 내용을 직접 요약한 것이고, 원문과 같은 CC BY-NC-SA 4.0으로 공유합니다. 문제의 저작자 표시는 Project Euler입니다.
  • Project Euler, Copyright — 문제 본문의 라이선스와 링크 방식. 비영리 조건에 따라 이 글에는 광고를 싣지 않습니다.
  • 풀이, 증명, 코드는 직접 쓴 것입니다. Project Euler의 해설(overview), 포럼, 회원 전용 자료나 다른 풀이 사이트는 참고하지 않았습니다. Motitory는 Project Euler와 제휴나 후원 관계가 없고, Project Euler가 이 글을 검토하지도 않았습니다.
  • C++17 최종 작업 초안 N4659 — §6.9.1 기본 타입(부호 없는 정수의 2^N 법 연산), §21.3.6 <climits>(C 표준의 범위를 따름), §22.3 assert(NDEBUG)
  • Python 표준 라이브러리: Numeric Types — int의 무제한 정밀도
  • Python 레퍼런스: The assert statement — 최적화 모드에서 assert가 제거됨

블로그로