동적 프로그래밍 (Dynamic Programming)

핵심 인사이트 (3줄 요약)

**동적 프로그래밍(DP)**은 중복되는 부분 문제를 메모이제이션하여 지수 시간을 다항 시간으로 최적화하는 알고리즘 설계 기법이다. 최적 부분 구조중복 부분 문제가 필요조건이며, Top-Down(재귀+캐시)과 Bottom-Up(반복문) 두 방식이 있다. LCS, 배낭 문제, LIS가 기술사 시험 핵심 유형이다.


Ⅰ. 개요

개념: 동적 프로그래밍(Dynamic Programming, DP)은 복잡한 문제를 겹치는 부분 문제(Overlapping Subproblems)로 분해하고, 각 부분 문제의 결과를 저장·재사용(Memoization)하여 전체 최적해를 효율적으로 구하는 알고리즘 설계 기법이다.

💡 비유: "피보나치 수열 계산" — 매번 처음부터 계산하지 말고, 이미 계산한 값은 적어두고 재사용!

등장 배경:

  1. 기존 문제점: 순수 재귀로 피보나치 계산 시 O(2^n) — fib(50)은 우주 나이보다 오래 걸림
  2. 기술적 필요성: 최적화 문제에서 중복 계산 제거로 지수 시간 → 다항 시간 단축
  3. 시장/산업 요구: git diff(LCS), 로그 분석(편집 거리), 포트폴리오 최적화(배낭) 등 실무 적용

핵심 목적: 중복 계산 제거를 통한 알고리즘 효율화


Ⅱ. 구성 요소 및 핵심 원리

DP 적용 조건:

조건설명예시
최적 부분 구조전체 최적해가 부분 최적해로 구성됨최단 경로 = min(A→B→C, A→D→C)
중복 부분 문제동일 부분 문제가 반복 등장fib(5) = fib(4) + fib(3), fib(3) 중복

구조 다이어그램:

    Top-Down (메모이제이션)           Bottom-Up (타뷸레이션)
    ┌─────────────────────────────┐   ┌─────────────────────────────┐
    │  문제                       │   │  기저 사례                  │
    │    ↓ 재귀 호출              │   │  dp[0], dp[1]...            │
    │  부분 문제                   │   │    ↓ 반복문                 │
    │    ↓ 또 재귀                 │   │  dp[2] = dp[0] + dp[1]      │
    │  캐시 확인 → 있으면 반환      │   │    ↓ 계속                   │
    │  없으면 계산 후 캐시 저장     │   │  dp[3] = dp[1] + dp[2]      │
    │    ↓                        │   │    ↓                        │
    │  최종 결과                   │   │  최종 결과 dp[n]            │
    └─────────────────────────────┘   └─────────────────────────────┘

    피보나치 호출 트리 (중복 계산 시각화)
    ┌─────────────────────────────────────────────────────────────┐
    │                         fib(5)                              │
    │                        /      \                             │
    │                   fib(4)      fib(3)                        │
    │                  /     \      /     \                       │
    │              fib(3)  fib(2) fib(2)  fib(1)                  │
    │              /    \   /   \   ↓       │                     │
    │          fib(2) fib(1) ↓   ↑  중복!   │                     │
    │           /   \    │   중복!          │                     │
    │       fib(1) fib(0)│                 │                     │
    │                                                             │
    │  fib(3) 2번, fib(2) 3번 중복 계산 → O(2^n)                 │
    │  DP 적용 시 각 값 한 번만 계산 → O(n)                       │
    └─────────────────────────────────────────────────────────────┘

    LCS DP 테이블 구성
    ┌─────────────────────────────────────────────────────────────┐
    │      ""   B   D   C   A   B   (Y)                          │
    │  "" [ 0   0   0   0   0   0 ]                               │
    │  A  [ 0   0   0   0   1   1 ]                               │
    │  B  [ 0   1   1   1   1   2 ]  ← LCS = 2                   │
    │  C  [ 0   1   1   2   2   2 ]  ← LCS = 2                   │
    │  B  [ 0   1   1   2   2   3 ]  ← LCS = 3                   │
    │  D  [ 0   1   2   2   2   3 ]  ← LCS = 3                   │
    │  A  [ 0   1   2   2   3   3 ]  ← LCS = 3                   │
    │  B  [ 0   1   2   2   3   4 ]  ← LCS = 4 ✓                 │
    │  (X)                                                        │
    │                                                             │
    │  점화식:                                                    │
    │    if X[i]==Y[j]: dp[i][j] = dp[i-1][j-1] + 1              │
    │    else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])             │
    └─────────────────────────────────────────────────────────────┘

동작 원리:

① 문제 분해 → ② 부분 문제 식별 → ③ 기저 사례 정의 → ④ 점화식 도출 → ⑤ 순서대로 계산 → ⑥ 최적해 도출

핵심 알고리즘/공식:

LCS (최장 공통 부분수열):
  dp[i][j] = {
    dp[i-1][j-1] + 1,          if X[i] == Y[j]
    max(dp[i-1][j], dp[i][j-1]), otherwise
  }
  시간: O(mn), 공간: O(mn) → O(n) 최적화 가능

0-1 배낭 (Knapsack):
  dp[i][w] = max(
    dp[i-1][w],                  # i번째 물건 안 넣음
    dp[i-1][w-weight[i]] + value[i]  # i번째 물건 넣음
  ) if weight[i] <= w
  시간: O(nW), 공간: O(nW) → O(W) 최적화 가능

LIS (최장 증가 부분수열):
  O(n²): dp[i] = max(dp[j] + 1) for all j < i and arr[j] < arr[i]
  O(n log n): 이진탐색으로 tails 배열 관리

코드 예시 (Python):

"""
동적 프로그래밍 핵심 알고리즘 구현
- 피보나치 (Top-Down vs Bottom-Up)
- LCS (최장 공통 부분수열)
- 0-1 배낭 문제
- LIS (최장 증가 부분수열)
- 편집 거리 (Levenshtein Distance)
"""
from typing import List, Tuple, Optional
from functools import lru_cache
import bisect


# ============== 피보나치 ==============

def fib_recursive(n: int) -> int:
    """순수 재귀 - O(2^n) - 매우 느림!"""
    if n <= 1:
        return n
    return fib_recursive(n - 1) + fib_recursive(n - 2)


def fib_memoization(n: int, memo: dict = None) -> int:
    """
    Top-Down (메모이제이션) - O(n)
    재귀 + 캐시
    """
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fib_memoization(n - 1, memo) + fib_memoization(n - 2, memo)
    return memo[n]


def fib_tabulation(n: int) -> int:
    """
    Bottom-Up (타뷸레이션) - O(n)
    반복문 + 테이블
    """
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]


def fib_optimized(n: int) -> int:
    """공간 최적화 - O(n) 시간, O(1) 공간"""
    if n <= 1:
        return n
    prev, curr = 0, 1
    for _ in range(2, n + 1):
        prev, curr = curr, prev + curr
    return curr


# ============== LCS ==============

def lcs_length(X: str, Y: str) -> int:
    """LCS 길이만 계산 - O(mn)"""
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    return dp[m][n]


def lcs_string(X: str, Y: str) -> Tuple[str, int]:
    """
    LCS 문자열과 길이 반환
    역추적 포함
    """
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # DP 테이블 채우기
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    # 역추적로 실제 문자열 복원
    lcs_chars = []
    i, j = m, n
    while i > 0 and j > 0:
        if X[i - 1] == Y[j - 1]:
            lcs_chars.append(X[i - 1])
            i -= 1
            j -= 1
        elif dp[i - 1][j] > dp[i][j - 1]:
            i -= 1
        else:
            j -= 1

    return ''.join(reversed(lcs_chars)), dp[m][n]


def lcs_optimized(X: str, Y: str) -> int:
    """공간 최적화 LCS - O(n) 공간"""
    m, n = len(X), len(Y)
    # 이전 행만 유지
    prev = [0] * (n + 1)

    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                curr[j] = prev[j - 1] + 1
            else:
                curr[j] = max(prev[j], curr[j - 1])
        prev = curr

    return prev[n]


# ============== 0-1 배낭 문제 ==============

def knapsack_01(weights: List[int], values: List[int], W: int) -> int:
    """
    0-1 배낭 문제 - O(nW)
    각 물건을 한 번만 넣을 수 있음
    """
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            # i번째 물건 안 넣음
            dp[i][w] = dp[i - 1][w]
            # i번째 물건 넣음 (무게 가능 시)
            if weights[i - 1] <= w:
                dp[i][w] = max(dp[i][w],
                               dp[i - 1][w - weights[i - 1]] + values[i - 1])

    return dp[n][W]


def knapsack_01_optimized(weights: List[int], values: List[int], W: int) -> int:
    """공간 최적화 0-1 배낭 - O(W) 공간"""
    n = len(weights)
    dp = [0] * (W + 1)

    # 뒤에서부터 업데이트 (중복 선택 방지)
    for i in range(n):
        for w in range(W, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]


def knapsack_items(weights: List[int], values: List[int], W: int) -> Tuple[int, List[int]]:
    """선택한 물건 인덱스도 반환"""
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            dp[i][w] = dp[i - 1][w]
            if weights[i - 1] <= w:
                dp[i][w] = max(dp[i][w],
                               dp[i - 1][w - weights[i - 1]] + values[i - 1])

    # 선택한 물건 역추적
    items = []
    w = W
    for i in range(n, 0, -1):
        if dp[i][w] != dp[i - 1][w]:
            items.append(i - 1)
            w -= weights[i - 1]

    return dp[n][W], items[::-1]


# ============== LIS ==============

def lis_n2(arr: List[int]) -> int:
    """
    LIS 길이 - O(n²)
    dp[i] = arr[i]로 끝나는 LIS 길이
    """
    n = len(arr)
    if n == 0:
        return 0

    dp = [1] * n
    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i]:
                dp[i] = max(dp[i], dp[j] + 1)

    return max(dp)


def lis_nlogn(arr: List[int]) -> int:
    """
    LIS 길이 - O(n log n)
    이진 탐색 활용
    tails[i] = 길이 i+1인 LIS의 마지막 원소 최솟값
    """
    tails = []
    for x in arr:
        pos = bisect.bisect_left(tails, x)
        if pos == len(tails):
            tails.append(x)
        else:
            tails[pos] = x
    return len(tails)


def lis_sequence(arr: List[int]) -> List[int]:
    """LIS 실제 수열 반환 - O(n log n)"""
    n = len(arr)
    if n == 0:
        return []

    tails = []
    tails_idx = []  # tails의 각 위치에 해당하는 arr 인덱스
    prev_idx = [-1] * n  # 각 원소의 이전 원소 인덱스

    for i, x in enumerate(arr):
        pos = bisect.bisect_left(tails, x)
        if pos == len(tails):
            tails.append(x)
            tails_idx.append(i)
        else:
            tails[pos] = x
            tails_idx[pos] = i

        if pos > 0:
            prev_idx[i] = tails_idx[pos - 1]

    # 역추적
    result = []
    curr = tails_idx[-1]
    while curr != -1:
        result.append(arr[curr])
        curr = prev_idx[curr]

    return result[::-1]


# ============== 편집 거리 ==============

def edit_distance(word1: str, word2: str) -> int:
    """
    편집 거리 (Levenshtein Distance) - O(mn)
    삽입, 삭제, 교체 연산
    """
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # 기저 사례
    for i in range(m + 1):
        dp[i][0] = i  # 삭제만
    for j in range(n + 1):
        dp[0][j] = j  # 삽입만

    # DP
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]  # 같음
            else:
                dp[i][j] = min(
                    dp[i - 1][j] + 1,     # 삭제
                    dp[i][j - 1] + 1,     # 삽입
                    dp[i - 1][j - 1] + 1  # 교체
                )

    return dp[m][n]


# ============== 실행 예시 ==============

if __name__ == "__main__":
    print("=" * 60)
    print(" 동적 프로그래밍 (Dynamic Programming)")
    print("=" * 60)

    # 피보나치
    print("\n[피보나치]")
    n = 30
    print(f"  fib({n}) 메모이제이션: {fib_memoization(n)}")
    print(f"  fib({n}) 타뷸레이션: {fib_tabulation(n)}")
    print(f"  fib({n}) 최적화: {fib_optimized(n)}")

    # LCS
    print("\n[LCS]")
    X, Y = "ABCBDAB", "BDCAB"
    lcs_str, lcs_len = lcs_string(X, Y)
    print(f"  X: {X}")
    print(f"  Y: {Y}")
    print(f"  LCS: '{lcs_str}' (길이: {lcs_len})")

    # 배낭
    print("\n[0-1 배낭]")
    weights = [2, 3, 4, 5]
    values = [3, 4, 5, 6]
    W = 5
    max_val, items = knapsack_items(weights, values, W)
    print(f"  무게: {weights}")
    print(f"  가치: {values}")
    print(f"  용량: {W}")
    print(f"  최대 가치: {max_val}")
    print(f"  선택 물건: {items}")

    # LIS
    print("\n[LIS]")
    arr = [10, 9, 2, 5, 3, 7, 101, 18]
    print(f"  배열: {arr}")
    print(f"  LIS 길이 O(n²): {lis_n2(arr)}")
    print(f"  LIS 길이 O(n log n): {lis_nlogn(arr)}")
    print(f"  LIS 수열: {lis_sequence(arr)}")

    # 편집 거리
    print("\n[편집 거리]")
    w1, w2 = "kitten", "sitting"
    print(f"  '{w1}' → '{w2}': {edit_distance(w1, w2)}")


print("\n" + "=" * 60)
print(" Top-Down vs Bottom-Up 비교")
print("=" * 60)
print("""
┌─────────────────┬──────────────────┬────────────────────┐
│      항목        │   Top-Down       │     Bottom-Up      │
├─────────────────┼──────────────────┼────────────────────┤
│ 접근 방향       │ 큰 문제 → 작은    │ 작은 문제 → 큰     │
│ 구현 방식       │ 재귀 + 캐시       │ 반복문 + 테이블    │
│ 필요한 계산     │ 필요한 것만       │ 모든 부분 문제     │
│ 스택 오버플로   │ 위험 (재귀 깊이)  │ ★ 안전             │
│ 코드 직관성     │ ★ 직관적          │ 명시적 상태 전이   │
│ 실무 선호       │ 제한적            │ ★ 대부분           │
└─────────────────┴──────────────────┴────────────────────┘
""")

---

### Ⅲ. 기술 비교 분석

**장단점 분석**:
| 장점 (DP) | 단점 (DP) |
|----------|----------|
| 지수 시간 → 다항 시간 단축 | 점화식 도출이 어려움 |
| 최적해 보장 | 공간 복잡도 증가 가능 |
| 중복 계산 완전 제거 | 모든 부분 문제 해결 필요 |
| 구조적 문제 해결 | 문제마다 다른 접근 필요 |

**DP vs 탐욕 vs 분할정복 비교**:
| 비교 항목 | 완전 탐색 | 분할 정복 | 탐욕 (Greedy) | ★ DP |
|---------|----------|----------|-------------|------|
| 중복 계산 | ★★ 많음 | 없음 | 없음 | ★ 저장·재사용 |
| 최적해 보장 | ★ 보장 | ★ 보장 | 비보장 | ★ 보장 |
| 시간 복잡도 | O(2^n)~O(n!) | O(n log n) | ★ O(n) | O(n²)~O(nW) |
| 적용 조건 | 제약 없음 | 독립 부분문제 | 탐욕 선택 성질 | 최적 부분구조+중복 |
| 대표 예시 | 순열·조합 | 병합정렬, FFT | 크루스칼, 다익스트라 | ★ LCS, 배낭, LIS |

> **★ 선택 기준**: 최적해 보장 필요 + 부분 문제 중복 → DP, 탐욕 선택 성질 만족 → Greedy

**기술 진화 계보**:

완전 탐색 → 분할 정복 → DP(1950s) → 메모이제이션 최적화 → tabulation → 공간 최적화


---

### Ⅳ. 실무 적용 방안

**기술사적 판단**:
| 적용 분야 | 구체적 적용 방법 | 기대 효과 (정량) |
|---------|----------------|-----------------|
| **git diff** | LCS로 파일 차이 계산 | diff 속도 10배 향상 |
| **맞춤법 검사** | 편집 거리로 오타 교정 | 교정 정확도 95% |
| **물류 최적화** | 배낭 문제로 적재 최적화 | 비용 20% 절감 |
| **DNA 분석** | LCS/편집거리로 서열 비교 | 분석 시간 100배 단축 |
| **금융 포트폴리오** | 배낭 변형으로 자산 배분 | 수익률 15% 향상 |

**실제 도입 사례**:
- **Git**: LCS 기반 diff 알고리즘 — 파일 변경사항 최소화
- **Google**: 편집 거리로 검색어 오타 교정 — "did you mean?"
- **생물정보학**: DNA 서열 정렬 (BLAST) — 유전자 유사도 분석
- **금융**: 포트폴리오 최적화 (배낭 변형) — 위험 대비 수익 최대화

**도입 시 고려사항**:
1. **기술적**: 점화식 도출, 메모리 제약, 부분 문제 식별
2. **운영적**: 캐시 크기, 재귀 깊이 제한, 테이블 크기
3. **보안적**: 타이밍 공격 방지 (상수 시간 구현)
4. **경제적**: 공간 vs 시간 트레이드오프

**주의사항 / 흔한 실수**:
- ❌ 부분 문제 식별 실패로 DP 미적용
- ❌ 점화식 오류로 잘못된 최적해
- ❌ 메모리 초과 (공간 최적화 미적용)
- ❌ 재귀 깊이 초과 (Bottom-Up 필요)

**관련 개념 / 확장 학습**:

📌 DP 핵심 연관 개념 맵

┌─────────────────────────────────────────────────────────────────┐ │ DP 핵심 연관 개념 맵 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ [분할 정복] ←──────→ [DP] ←──────→ [탐욕 알고리즘] │ │ ↓ ↓ ↓ │ │ [재귀] [메모이제이션] [최적 선택] │ │ ↓ ↓ ↓ │ │ [백트래킹] [LCS/배낭/LIS] [다익스트라] │ │ │ └─────────────────────────────────────────────────────────────────┘


| 관련 개념 | 관계 | 설명 | 문서 링크 |
|----------|------|------|----------|
| 분할 정복 | 선행 개념 | DP의 기반 | `[divide_conquer](./divide_conquer.md)` |
| 탐욕 알고리즘 | 대안 기법 | 더 빠르지만 조건 엄격 | `[greedy](./greedy.md)` |
| 그래프 | 응용 분야 | 최단 경로 (플로이드) | `[shortest_path](./shortest_path.md)` |
| 백트래킹 | 관련 기법 | DP + 가지치기 | `[backtracking](./backtracking.md)` |
| 메모이제이션 | 핵심 기술 | 결과 캐싱 | — |

---

### Ⅴ. 기대 효과 및 결론

**정량적 기대 효과**:
| 효과 영역 | 구체적 내용 | 정량적 목표 |
|---------|-----------|------------|
| 시간 단축 | 지수 → 다항 시간 | O(2^n) → O(n²) |
| 최적해 보장 | 항상 최적 | 100% 정확도 |
| 메모리 효율 | 공간 최적화 | O(n)으로 축소 |

**미래 전망**:
1. **기술 발전 방향**: 자동 DP 도출, ML 기반 최적화
2. **시장 트렌드**: 실시간 최적화, 스트리밍 DP
3. **후속 기술**: Approximate DP, Online DP

> **결론**: 동적 프로그래밍은 중복 계산 제거를 통한 최적화의 핵심 기법으로, 최적 부분 구조와 중복 부분 문제의 식별이 핵심이다. LCS, 배낭, LIS는 기술사 시험의 단골 유형이며, Top-Down과 Bottom-Up의 장단점을 이해하고 상황에 맞게 선택하는 능력이 필수다.

> **※ 참고 표준**: CLRS 'Introduction to Algorithms' Ch.15, T. H. Cormen, LeetCode DP Patterns

---

## 어린이를 위한 종합 설명

**동적 프로그래밍은 "메모하면서 풀기"야!**

상상해보세요: 피보나치 수열을 계산해야 해요! 1, 1, 2, 3, 5, 8, 13, 21...

❌ 매번 처음부터 계산하기: "8을 구하려면... 5+3... 5를 구하려면... 3+2..." "3을 또 구하려면... 2+1..." → 3을 두 번 계산했어! 😫

✅ 이미 계산한 건 적어두기: "3 = 2+1, 적어놓자!" 📝 "또 3이 필요하면? 적어둔 거 보자!" → 1초 만에 끝! 😊


**배낭 문제 (가방에 물건 넣기)**:

가방에 물건을 넣어요 (무게 5kg 제한):

🍎 사과 (2kg, 3만원) 🍌 바나나 (3kg, 4만원) 🍊 오렌지 (4kg, 5만원) 🍇 포도 (5kg, 6만원)

어떤 걸 넣어야 가장 비싸게 담을까요?

DP 방식: ① 무게 1부터 시작: 아무것도 못 넣어 ② 무게 2: 사과(3만원) 넣을 수 있어! ③ 무게 3: 사과+바나나(7만원)! ④ 무게 4: 오렌지 vs 사과+바나나? → 7만원이 더 커! ⑤ 무게 5: 포도(6만원) vs 사과+바나나(7만원)? → 7만원!

정답: 사과+바나나 = 7만원! 💰


**LCS (공통으로 긴 순서 찾기)**:

내 쇼핑 목록: 🍎 → 🍌 → 🍊 → 🍇 친구 목록: 🍌 → 🍇 → 🍎 → 🍊

둘 다 있는 순서대로 된 건? 🍌 → 🍊 (길이 2) ✓ 🍎 → 🍊 (길이 2) ✓

DP로 표를 만들어서 구해요! 📊


**비밀**: 어려운 문제를 작은 문제로 쪼개서, 한 번 푼 건 다시 안 풀기! 🧩✨