DP 메모이제이션: 피보나치의 중복 호출 줄이기

··설명 약 8분·작성: 해비·블로그 소개

피보나치 메모이제이션은 같은 상태의 중복 재귀 호출을 어떻게 줄이나요?

메모이제이션은 같은 입력의 답을 한 번만 계산해 두는 방법입니다. 재귀식을 바꾸는 대신 이미 구한 답을 꺼내 중복 호출을 줄입니다.

적용 조건·범위: 분석 조건: 정수 0≤n≤78. 숫자 연산을 상수 비용으로 보는 분석이며 저장 방식의 평균 접근 비용을 전제합니다.

본문 근거: Competitive Programmer’s Handbook · MDN — Number.MAX_SAFE_INTEGER · ECMAScript의 안전한 정수 규칙

읽기 전에 알아두면 좋아요

아래 개념이 익숙하면 본문을 바로 읽으세요. 낯선 개념만 링크에서 복습하면 됩니다.

개념과 예제는 실행하지 않아도 읽을 수 있습니다. 내 컴퓨터·터미널에서 직접 실행하는 과정은 선택 실습입니다.

과정 위치와 선택 복습 안내

과정 위치: 동적 계획법의 두 계산 순서 · 알고리즘 개념

과정 위치는 이 글이 속한 묶음입니다. 이 단계의 모든 개념을 먼저 읽을 필요는 없습니다. 위에 연결한 개념 중 낯선 내용만 복습하세요. 문제의 조건·상태·갱신 순서는 익숙한 언어로 설명하면 됩니다.

JavaScript로 문법·실행 복습하기 (선택)

개념 글의 코드는 JavaScript 예시입니다. 문제 화면에서는 JavaScript·Python·Java·C++·C# 중 익숙한 언어의 함수를 작성하세요. 본문의 Node.js 입출력 예시는 로컬 실행을 위한 별도 안내입니다.

DP 메모이제이션: 피보나치의 중복 호출 줄이기 주제를 표현한 밝은 일러스트와 작은 곰 표지

메모이제이션은 같은 입력의 답을 한 번만 계산해 두는 방법입니다. 재귀식을 바꾸는 대신 이미 구한 답을 꺼내 중복 호출을 줄입니다.

선수 지식: 함수의 반환값, 재귀의 종료 조건, Map의 get·has·set을 이해하면 좋습니다.

  • 반복해서 나타나는 같은 상태를 찾습니다.
  • 정답을 계산한 뒤 저장하고 재사용하는 순서를 설명합니다.

F(5)를 구할 때 F(3)이 다시 나타납니다

피보나치는 F(0)=0, F(1)=1이고 이후에는 바로 앞의 두 값을 더합니다. F(5)를 계산하려면 F(4)와 F(3)이 필요하고, F(4)를 풀 때도 F(3)을 다시 요청합니다. 두 요청은 이름만 비슷한 다른 일이 아니라 입력이 3인 같은 문제입니다. 이때 입력 n을 상태라고 부릅니다. 이미 구한 F(3)=2를 저장했다면 두 번째 요청에서 그 아래의 재귀를 펼칠 필요가 없습니다.

F(5)의 두 갈래에서 반복되는 F(3)을 Map의 저장값 2로 처리하는 관계
F(3)의 답이 한 번 완성되면 같은 상태의 재요청은 저장된 2를 반환합니다. 저장 여부는 값의 참·거짓 대신 항목의 존재로 확인합니다. 원본 보기 (새 탭)
작은 입력에서 달라지는 상태
요청 이미 저장된 답 할 일
F(2) F(0)=0, F(1)=1 1을 계산해 저장
F(3) F(2)=1, F(1)=1 2를 계산해 저장
다시 F(3) F(3)=2 저장된 2를 반환

0도 올바른 답이므로 if (memo.get(k))처럼 값의 참·거짓으로 저장 여부를 판단하면 안 됩니다. 이 예제는 memo.has(k)로 항목 자체의 존재를 확인합니다. 답을 저장하는 시점은 두 하위 문제의 계산이 끝난 뒤입니다.

저장할 키에는 답을 결정하는 정보가 모두 있어야 합니다

이 피보나치에서는 n 하나로 정답이 결정됩니다. 앞에서 어떤 호출을 거쳤는지나 현재까지 더한 값은 정답의 조건을 바꾸지 않으므로 키에 필요하지 않습니다. 반면 남은 예산과 현재 위치가 함께 답을 바꾸는 문제라면 두 값 모두 상태에 포함해야 합니다. 서로 다른 문제를 같은 키로 합치면 빨라지는 것이 아니라 다른 답을 잘못 재사용하게 됩니다.

피보나치의 단일 키 n과 위치와 예산이 함께 필요한 문제의 복합 키 비교
답을 결정하는 입력 조건을 모두 키에 담습니다. 서로 다른 상태의 답이 같을 수 있어도, 필요한 조건을 빼서 같은 키로 합쳐서는 안 됩니다. 원본 보기 (새 탭)

함수 안에서 memo를 새로 만들었으므로 다른 실행의 결과와 섞이지 않습니다. 같은 n에 대한 여러 최상위 요청에서도 저장소를 공유하고 싶다면 입력 계약과 수명을 따로 설계해야 합니다. 여기서는 한 호출의 중복을 없애는 원리를 먼저 익힙니다.

재귀가 사라진 것은 아닙니다

저장 덕분에 각 상태의 본 계산은 한 번만 수행하지만, 처음 내려갈 때 F(n)→F(n−1)→…의 호출은 남습니다. 따라서 반복 계산 횟수와 호출 스택 깊이를 따로 세어야 합니다. 이 예제는 Number로 정확한 정수를 반환하기 위해 n을 0~78로 제한합니다. 79 이상을 같은 숫자형으로 계산한 뒤 정밀한 정답이라고 설명하지 않습니다.

메모이제이션으로 중복 계산을 줄여도 처음 내려가는 재귀 경로와 호출 깊이는 남는 구조
저장된 답이 있으면 하위 재귀를 다시 펼치지 않습니다. 호출 스택 자체가 사라지는 것은 아니며 Number 정수 예제는 0~78만 받습니다. 원본 보기 (새 탭)

큰 n에 단순 재귀를 실행해 기다리는 대신 작은 n의 호출 관계부터 추적하세요. 시간이 얼마나 걸렸는지보다 서로 다른 상태가 몇 개인지, 상태마다 무슨 일을 하는지를 설명하는 것이 다른 DP 문제에도 적용됩니다.

전체 코드와 직접 실행

개념을 읽었다면 아래 예제를 직접 실행하며 상태 변화를 확인할 수 있습니다.

전체 JavaScript 코드와 실행 방법 펼치기

별도 패키지는 필요하지 않습니다. Node.js가 처음이라면 브라우저와 Node.js 시작 안내부터 확인하세요.

dp-memoization.mjs 파일을 만들고 아래 전체 코드를 저장합니다.

function checkFibonacciInput(n) {
  if (!Number.isInteger(n) || n < 0 || n > 78) {
    throw new RangeError('n은 0 이상 78 이하의 정수여야 합니다.');
  }
}

function fibonacciMemo(n) {
  checkFibonacciInput(n);
  const memo = new Map([[0, 0], [1, 1]]);
  function solve(k) {
    if (memo.has(k)) return memo.get(k);
    const value = solve(k - 1) + solve(k - 2);
    memo.set(k, value);
    return value;
  }
  return solve(n);
}

console.log(fibonacciMemo(10));

파일이 있는 폴더의 터미널에서 실행합니다.

node dp-memoization.mjs

기대 출력

55

비용과 적용 범위

시간: 0부터 n까지의 상태를 한 번씩 계산하므로 O(n)번의 덧셈·저장 연산입니다.

상태 수 O(n)과 메모 저장소 및 첫 재귀 호출 스택의 선형 공간 비용
각 상태를 한 번 계산하는 비용과 memo·호출 스택을 함께 셉니다. 오른쪽은 n≥2인 일반 재귀 경로의 개념도이며, 숫자 연산과 평균 저장소 접근을 상수 비용으로 봅니다. 원본 보기 (새 탭)

공간: memo O(n), 첫 재귀 호출 스택 O(n)입니다.

분석 조건: 정수 0≤n≤78. 숫자 연산을 상수 비용으로 보는 분석이며 저장 방식의 평균 접근 비용을 전제합니다.

직접 확인해 보기

F(6)을 계산하다 두 번째로 F(4)를 요청하면 어떤 값을 반환하며, F(3)과 F(2)를 다시 풀어야 하나요?

F(6) 계산 중 두 번째 F(4) 요청과 저장값을 묻는 확인 문제
F(4)를 다시 요청한 상황입니다. 어떤 값을 반환하고 하위 상태를 다시 풀어야 하는지 직접 판단해 보세요. 원본 보기 (새 탭)
답과 이유 확인

저장된 3을 반환합니다. F(4)를 처음 완성할 때 이미 저장했으므로 그 아래의 재귀를 다시 펼치지 않습니다.

참고 자료

  • Competitive Programmer’s Handbook — 동적 계획법의 상태·전이, 부분집합과 순회 최적화의 일반 원리를 대조합니다. 예제와 설명은 이 원고의 독립 구현입니다.
  • MDN — Number.MAX_SAFE_INTEGER — Number 정수 정밀도의 한계와 이 예제의 피보나치 상한을 구분합니다.

피보나치 원본 비교: 실행시간보다 호출과 저장을 셉니다

첨부 dev/fibonacci2.mjs는 단순 재귀 fibonacci1(n)과 메모이제이션 fibonacci2(n, memo)를 같은 파일에서 비교합니다. 두 함수의 목표는 같은 숫자 F(n)입니다. 메모이제이션이 줄이는 것은 정답 자체가 아니라 이미 계산한 상태 아래의 반복 호출입니다. 앞에서 배운 저장 원리를 실제 호출 수에 연결해 보겠습니다.

n−2부터 계산하는 원본의 F(5) 호출 순서와 단순 재귀 15회, 메모이제이션 9회 비교
원본 비교는 n−2를 먼저 계산합니다. F(5)의 답은 같고, 저장된 답을 꺼내는 진입도 호출 수에 포함합니다. 고정 배속 대신 호출과 저장을 비교합니다. 원본 보기 (새 탭)

원본은 n – 2를 먼저 계산합니다

원본의 식은 fibonacci(n - 2) + fibonacci(n - 1)입니다. 따라서 F(5)를 요청하면 왼쪽 F(3)부터 내려갑니다. F(3)은 F(1)과 F(2)를 구해 2를 저장하고, 이어 오른쪽 F(4)는 저장된 F(2)=1과 F(3)=2를 꺼내 3을 저장합니다. 마지막으로 F(5)=2+3=5입니다. 앞선 개념 설명의 큰 하위 상태부터 따라가는 순서와 달라도 정답은 같습니다.

원본의 호출 순서로 F(5)를 계산한 비교
관찰 항목 단순 재귀 메모이제이션
반환한 F(5) 5 5
함수에 들어간 전체 횟수 15 9
처음 계산하여 저장한 상태 저장 없음 F(2), F(3), F(4), F(5)
저장된 답으로 끝낸 호출 없음 F(2), F(3)의 재요청 2회

여기서 전체 호출에는 첫 호출과 F(0)·F(1) 기저 호출도 포함합니다. 저장된 답을 꺼내는 호출도 함수에 들어간 횟수에는 포함하지만, 두 하위 상태를 다시 풀지는 않습니다. 기저 조건은 저장소 검사보다 먼저 반환하므로 위의 적중 횟수에는 F(0)·F(1)을 넣지 않습니다. F(6)에서는 두 방식의 전체 호출이 각각 25회와 11회가 됩니다.

memo의 수명과 입력 계약도 비교합니다

원본의 두 번째 함수는 호출자가 {}를 전달하고, 그 객체를 재귀 호출들이 공유합니다. memo[n] == null은 값이 null 또는 undefined일 때 계산하겠다는 뜻입니다. if (!memo[n])처럼 0을 미저장 상태로 취급하는 검사와 다릅니다. 다만 호출자가 잘못 채운 객체도 그대로 신뢰하고, n≥2에서 저장소를 생략하면 오류가 납니다. 학습용 독립 예제는 매 비교마다 내부 Map을 새로 만들고 has로 존재를 검사합니다.

원본은 ==를 써서 문자열 "1"도 기저 조건을 통과하며 그 문자열 자체를 반환할 수 있습니다. 음수나 소수는 정상적인 종료를 보장하지 않습니다. 아래 비교 함수는 숫자형 정수만 받고, 단순 재귀도 함께 실행하므로 0~20으로 제한합니다. 기존 본문의 메모이제이션 전용 예제 0~78 범위와는 목적이 다른 제한입니다. 이 범위를 넘는 비교 입력은 오류로 알립니다.

원본 두 함수는 F(n) 숫자 하나를 반환합니다. 아래의 내부 함수도 숫자를 반환하지만, 바깥 compareFibonacci(n)는 두 답과 호출 수·적중 수·저장 순서를 담은 객체를 반환합니다. 계산 과정이 보이도록 추가한 반환 계약이며, 소요 시간을 반환하는 함수는 아닙니다.

밀리초 차이는 복잡도의 증명이 아닙니다

원본은 n=40에서 두 답을 출력하고 Date의 차이로 시간을 잽니다. 두 답은 모두 102334155이지만, 밀리초 값은 실행 환경마다 달라집니다. 원본의 측정 구간에는 결과를 출력하는 console.log도 들어갑니다. 한 번의 측정에는 출력 비용·실행 준비·다른 작업의 영향이 섞이므로 고정된 시간이나 배속을 학습 결론으로 쓰지 않습니다. 아래에서는 작은 n의 호출 수를 셉니다.

단순 재귀의 호출 수는 빠르게 늘며 O(2ⁿ)은 그 상한입니다. 메모이제이션은 각 상태를 한 번 계산하므로 숫자 덧셈과 저장소 접근을 상수 비용으로 보는 조건에서 O(n) 시간입니다. 둘 다 가장 깊은 호출 경로의 스택은 O(n)이고, 메모이제이션에는 O(n) 저장소도 필요합니다. 아래 비교 도구는 두 실행과 관찰 기록을 모두 포함하므로 도구 전체가 O(n)이라고 해석하면 안 됩니다. Number 정수 범위는 ECMAScript의 안전한 정수 규칙과 별도로 확인해야 합니다.

추가 예제 코드와 실행 방법 펼치기

직접 실행할 때만 펼쳐 보세요. Node.js 시작 안내를 확인하고 supplement-fibonacci-call-counts.js로 저장합니다.

function compareFibonacci(n) {
  if (!Number.isInteger(n) || n < 0 || n > 20) {
    throw new RangeError('비교 예제는 정수 0~20만 받습니다.');
  }
  let plainCalls = 0;
  let memoCalls = 0;
  let memoHits = 0;
  const computedStates = [];
  const cache = new Map();

  function plain(k) {
    plainCalls++;
    if (k === 0 || k === 1) return k;
    return plain(k - 2) + plain(k - 1);
  }

  function memo(k) {
    memoCalls++;
    if (k === 0 || k === 1) return k;
    if (cache.has(k)) {
      memoHits++;
      return cache.get(k);
    }
    const value = memo(k - 2) + memo(k - 1);
    cache.set(k, value);
    computedStates.push(k);
    return value;
  }

  const plainValue = plain(n);
  const memoValue = memo(n);
  return { n, plainValue, memoValue, plainCalls, memoCalls, memoHits, computedStates };
}

console.log(JSON.stringify(compareFibonacci(5)));
console.log(JSON.stringify(compareFibonacci(6)));

파일이 있는 폴더의 터미널에서 실행합니다.

node supplement-fibonacci-call-counts.js

기대 출력

{"n":5,"plainValue":5,"memoValue":5,"plainCalls":15,"memoCalls":9,"memoHits":2,"computedStates":[2,3,4,5]}
{"n":6,"plainValue":8,"memoValue":8,"plainCalls":25,"memoCalls":11,"memoHits":3,"computedStates":[2,3,4,5,6]}

이 글이 도움이 되었나요?

조회 중

이어서 공부하기

체크포인트: 예제의 상태 변화를 손으로 추적하고 아래 문제에 적용하세요. 값이 없는 경우와 원소 하나인 경우도 설명해 보세요. 같은 부분 문제를 다시 계산하는 지점과 저장한 값을 재사용하는 지점을 구분하세요.

다음 추천 · 알고리즘 개념: DP 타뷸레이션: 작은 답부터 테이블 채우기

같은 주제에서 다음 개념과 예제로 이해를 넓힙니다.

권장 순서는 학습 가이드와 같습니다. 다른 순서는 접힌 참고 목록에서 확인할 수 있습니다. 읽음 표시는 숙달이나 채점 결과가 아닙니다.

원래 과정 목록 순서 (선택)

읽음 기록

읽음 — / 전체 30개 · 필수 0개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기