BlueNyang
[멀티캠퍼스] 풀스택 개발자 아카데미 (16) - Algorithm(5)
Java 풀스택 아카데미
· 멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차 (16편)

[멀티캠퍼스] 풀스택 개발자 아카데미 (16) - Algorithm(5)

BlueNyangBlueNyang
·
·
약 5분
·
# 부트캠프후기# 멀티캠퍼스it부트캠프# [현대이지웰] JAVA 풀스택 개발자 아카데미 6회차# dynamic-programming# memoization# tabulation
시리즈·멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차(30개의 글)
  • ···
  • 15.[멀티캠퍼스] 풀스택 개발자 아카데미 (15) - Algorithm(4)
  • 16.[멀티캠퍼스] 풀스택 개발자 아카데미 (16) - Algorithm(5)현재
  • 17.[멀티캠퍼스] 풀스택 개발자 아카데미 (17) - Servlet/JSP(1)
  • ···

0. 동적 프로그래밍

이전 글까지 4편에 걸쳐 기본기부터 브루트 포스, 정렬, 그리고 탐욕 알고리즘까지, 다양한 문제해결 방법을 알아봤다.

오늘은 그 정점에 있는, 복잡한 문제들을 우아하고 효율적으로 해결하는 궁극의 패러다임, **동적 프로그래밍(Dynamic Programming, DP)**에 대해 알아본다.

4편에서 우리는 '매 순간의 최선'을 선택하는 탐욕 알고리즘이 때로는 최적의 해를 놓칠 수 있다는 한계를 확인했다. 동적 프로그래밍은 바로 이 지점에서 출발한다. 모든 가능한 선택지를 고려하되, 어리석게 모든 계산을 반복하지 않고 '기억(메모)'을 통해 효율을 극대화하는 현명한 전략이다.

동적 프로그래밍은 복잡한 문제를 여러 개의 **작은 부분 문제(Subproblem)**로 나누어 푼다는 점에서 분할 정복과 유사하다. 하지만 결정적인 차이는 부분 문제들이 서로 중복되는지 여부이다.

분할 정복의 부분 문제들은 서로 독립적이지만, DP의 부분 문제들은 여러 번 반복해서 재사용된다.

동적 프로그래밍의 핵심 개념을 보여주는 다이어그램
동적 프로그래밍의 핵심 개념을 보여주는 다이어그램

DP는 이 중복되는 부분 문제들의 계산 결과를 한 번만 구하고, 그 결과를 자료구조(주로 배열이나 해시 테이블)에 저장해 두었다가 나중에 필요할 때 다시 꺼내 쓰는 방식을 사용한다. 이를 통해 지수적으로 늘어날 수 있는 계산량을 획기적으로 줄일 수 있다.

DP를 적용하기 위해서는 문제가 다음 두 가지 핵심 속성을 만족해야 한다.

  • 중복되는 부분 문제 (Overlapping Subproblems): 큰 문제의 해답을 구하는 과정에서, 동일한 작은 문제의 해답이 반복적으로 필요해야 한다.

    • 예를 들어 '피보나치 수열'에서 fib(5)를 구하려면 fib(4)와 fib(3)이 필요하고, fib(4)를 구하려면 또 fib(3)과 fib(2)가 필요한 것처럼, fib(3)이 여러 번 호출되는 구조를 가져야 한다.
  • 최적 부분 구조 (Optimal Substructure): 큰 문제의 최적 해가, 그 문제의 작은 부분 문제들의 최적 해를 이용해 구성될 수 있어야 한다. 즉, 부분 문제들의 최적 해를 올바르게 조합하면 전체 문제의 최적 해를 얻을 수 있다는 믿음이 전제되어야 한다.

1. DP의 두 가지 접근법

1.1. 메모이제이션 (Memoization): Top-Down 방식

메모이제이션은 우리가 일반적으로 생각하는 재귀 호출에 '기록'이라는 개념을 더한 것이다.

큰 문제에서 출발하여 작은 문제로 내려가면서(Top-Down) 답을 구한다.

1.1.1. 해결 과정

  1. 문제를 재귀적으로 호출하며 해를 구한다.
  2. 특정 부분 문제의 해를 처음 구했다면, 그 결과를 memo라는 배열이나 맵에 저장한다.
  3. 다음에 같은 부분 문제를 만나면, 다시 계산하지 않고 memo에 저장된 값을 즉시 반환한다.
메모이제이션의 동작 원리 (재귀 + 캐시)
메모이제이션의 동작 원리 (재귀 + 캐시)

1.1.2. 장단점

  • 장점: 재귀적인 구조를 그대로 코드로 옮길 수 있어 더 직관적이다. 또한, 필요한 부분 문제만 계산하므로 타뷸레이션에 비해 불필요한 계산을 줄일 수 있다.
  • 단점: 재귀 호출의 깊이가 너무 깊어지면 **스택 오버플로우(Stack Overflow)**가 발생할 수 있다.

1.2. 타뷸레이션 (Tabulation): Bottom-Up 방식

타뷸레이션은 재귀 대신 반복문을 사용하여 가장 작은 부분 문제부터 차례대로 해를 구해나가는 방식이다. 마치 표(Table)를 아래에서부터 위로 채워나가는(Bottom-Up) 모습과 같다.

1.2.1. 해결 과정

  1. 부분 문제들의 결과를 저장할 dp라는 이름의 배열(Table)을 만든다.
  2. 가장 작은, 즉 초기값이 명확한 부분 문제부터 dp 배열을 채워나간다.
  3. 반복문을 통해 점화식(Recurrence Relation)을 기반으로 dp 배열을 끝까지 채웁니다. 최종적으로 dp 배열의 마지막 부분이 우리가 원하는 해가 된다.
타뷸레이션의 동작 원리 (반복문 + 테이블)
타뷸레이션의 동작 원리 (반복문 + 테이블)

1.2.2. 장단점

  • 장점: 단순 반복문으로 구현되므로 재귀로 인한 오버헤드가 없고 스택 오버플로우의 위험이 없다.
  • 단점: 문제에 따라 반복문의 순서(dp 테이블을 채워나가는 순서)를 정하는 것이 까다로울 수 있다. 때로는 최종 답을 위해 필요 없는 부분 문제까지 모두 계산해야 할 수 있다.

2. 예제

2.1. 대표 예제 1

피보나치 수열피보나치 수열은 DP의 두 가지 속성을 가장 잘 보여주는 교과서적인 예제다.

문제: n번째 피보나치 수를 구하시오. $$F_n = F_ + F_$$
  • 순진한 재귀 접근 (O(2ⁿ)): fib(5)를 구하기 위해 fib(4)와 fib(3)을 호출하고, fib(4)는 다시 fib(3)과 fib(2)를 호출합니다. fib(3)이 중복 호출되면서 엄청난 비효율이 발생합니다.
피보나치 수열의 재귀 호출 트리 (중복 호출 시각화)
피보나치 수열의 재귀 호출 트리 (중복 호출 시각화)
  • 메모이제이션 (Top-Down) 접근 (O(n)):
java
int[] memo = new int[n+1]; // 결과를 저장할 배열

public int fib_memo(int n) {
    if (n <= 1) {
        return n;
    }

    if (memo[n] != 0)  {
        return memo[n]; // 이미 계산한 값이면 즉시 반환
    }

    // 계산하고 결과 저장
    return memo[n] = fib_memo(n - 1) + fib_memo(n - 2);
}
  • 타뷸레이션 (Bottom-Up) 접근 (O(n)):
java
public int fib_tab(int n) {
    if (n <= 1) {
        return n;
    }

    int[] dp = new int[n + 1];

    dp[0] = 0;
    dp[1] = 1;

    for (int i = 2; i <= n; i++) {
      dp[i] = dp[i - 1] + dp[i - 2]; // 아래부터 테이블 채우기
    }

    return dp[n];
}

2.1.1. 타뷸레이션의 최적화

타뷸레이션 접근을 살펴보면, dp[i]는 dp[i-1]과 dp[i-2]만을 필요로 하고 있다. 그렇다면, 굳이 전체 배열을 다 저장할 필요가 없을 거라는 생각을 가져볼 수 있다.

  • 공간 복잡도 최적화
java
public int fib_optimized(int n) {
    if (n <= 1) {
        return n;
    }

    // n-2, n-1 번째 값을 저장할 변수 초기화
    int prev2 = 0; // F(0)
    int prev1 = 1; // F(1)
    int current = 0;

    // 2부터 n까지 반복하며 이전 두 항의 합을 구해 현재 항을 갱신
    for (int i = 2; i <= n; i++) {
        current = prev1 + prev2;
        prev2 = prev1;     // 다음 반복을 위해 한 칸씩 이동
        prev1 = current;   // 다음 반복을 위해 한 칸씩 이동
    }

    return current;
}

2.2. 대표 예제 2

문제: n개의 정수로 이루어진 임의의 수열이 주어진다. 이 중 연속된 몇 개의 수를 선택해 구할 수 있는 합 중 가장 큰 합을 구하시오. (예: 10, -4, 3, 1, 5, 6, -35, 12, 21, -1)
수열에서 가장 큰 합을 가지는 부분 수열을 강조한 이미지
수열에서 가장 큰 합을 가지는 부분 수열을 강조한 이미지
  • DP 상태 정의: dp[i] = i번째 원소로 반드시 끝나는 연속 부분 수열의 최대 합.
  • 점화식 도출: i번째에서 끝나는 최대 합은 두 가지 경우밖에 없다.

    • i번째 원소 혼자서 새로운 연속 부분 수열을 시작한다. (값: arr[i])
    • i-1번째에서 끝나는 최대 합에 i번째 원소를 이어 붙인다. (값: dp[i-1] + arr[i] 따라서, 점화식은 dp[i] = max(arr[i], dp[i-1] + arr[i]) 가 된다.
  • 논리적 과정 (타뷸레이션): dp 배열을 채우면서, 동시에 전체 최대 합 maxSum을 갱신한다.
최대 연속 부분 수열 합 문제의 DP 테이블 계산 과정
최대 연속 부분 수열 합 문제의 DP 테이블 계산 과정
  • Java 코드 예시:
java
public int maxSubArray(int[] arr) {
    if (arr == null || arr.length == 0) return 0;
    int[] dp = new int[arr.length];
    dp[0] = arr[0];
    int maxSum = dp[0];

    for (int i = 1; i < arr.length; i++) {
        dp[i] = Math.max(arr[i], dp[i - 1] + arr[i]);
        maxSum = Math.max(maxSum, dp[i]);
    }
    return maxSum;
}
Note
해당 문제는 DP로도 해결할 수 있지만, 최적화된 알고리즘은 카데인 알고리즘으로 알려져 있다. dp 배열 대신, 이전 원소까지의 최대 합을 저장하는 변수만 유지하므로써 공간복잡도를 O(1)로 최적화할 수 있다.

3. 마치며

지난 다섯 편의 글을 통해 다양한 알고리즘을 알아보았다.

1편의 자료구조라는 기본 도구를 챙기고, 2편에서는 브루트 포스와 DFS/BFS로 모든 길을 탐색하는 법을 배웠다. 3편에서는 정렬과 분할 정복으로 문제의 스케일을 다루는 법을, 4편에서는 탐욕 알고리즘으로 최적값을 찾는 법을 알아보았다.

그리고 오늘, 동적 프로그래밍이라는 가장 정교한 전략으로 마무리했다. 이 패러다임들은 단순히 코딩 테스트를 통과하기 위한 지식이 아니다. 이것들은 복잡하고 추상적인 현실의 문제를 논리적인 단계로 분해하고, 가장 효율적인 해결책을 설계하는 SW 엔지니어의 언어로 볼 수 있다.

어떤 문제를 만났을 때 "이건 DP로 풀 수 있겠다" 또는 "이건 Greedy가 통하겠는데?"라고 생각할 수 있는 능력이야말로, 단순히 코드를 작성하는 Coder를 넘어 문제를 해결하는 Problem Solver로 거듭나는 열쇠이다.

BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus