개념

DP (Dynamic Programming) - 큰 문제를 작은 문제로 나누어 해결

중복되는 부분 문제의 결과를 저장하여 재사용


시간복잡도

방법시간복잡도공간복잡도
일반 재귀O(2^n) ~ O(n!)O(n)
DP (메모이제이션)O(n) ~ O(n²)O(n)
DP (타뷸레이션)O(n) ~ O(n²)O(n)

특징

DP를 사용하는 조건

  1. 최적 부분 구조 (Optimal Substructure)

    • 큰 문제의 최적해가 작은 문제의 최적해로 구성
  2. 중복되는 부분 문제 (Overlapping Subproblems)

    • 같은 부분 문제가 여러 번 계산됨

장점

  • 시간 단축: 지수 시간 → 다항 시간
  • 최적해 보장: 모든 경우를 고려

단점

  • 메모리 사용: 결과 저장 공간 필요
  • 문제 분석: DP 식 도출이 어려울 수 있음

구현 방법

1. 메모이제이션 (Memoization) - Top-Down

재귀 + 캐싱

# 피보나치 - 메모이제이션
def fibonacci_memo(n, memo={}):
    if n in memo:
        return memo[n]
 
    if n <= 1:
        return n
 
    memo[n] = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo)
    return memo[n]
 
print(fibonacci_memo(10))  # 55
 
# 시간복잡도: O(n)
# 공간복잡도: O(n)

2. 타뷸레이션 (Tabulation) - Bottom-Up

반복문 + 배열

# 피보나치 - 타뷸레이션
def fibonacci_tab(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]
 
print(fibonacci_tab(10))  # 55
 
# 시간복잡도: O(n)
# 공간복잡도: O(n)

3. 공간 최적화

# 피보나치 - 공간 O(1)
def fibonacci_optimized(n):
    if n <= 1:
        return n
 
    prev2, prev1 = 0, 1
 
    for _ in range(2, n + 1):
        current = prev1 + prev2
        prev2, prev1 = prev1, current
 
    return prev1
 
print(fibonacci_optimized(10))  # 55
 
# 시간복잡도: O(n)
# 공간복잡도: O(1)

자주 나오는 문제 유형

1. 계단 오르기

문제: n개의 계단을 1칸 또는 2칸씩 오르는 방법의 수

def climb_stairs(n):
    if n <= 2:
        return n
 
    dp = [0] * (n + 1)
    dp[1] = 1
    dp[2] = 2
 
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
 
    return dp[n]
 
# 테스트
print(climb_stairs(5))  # 8
 
# 시간복잡도: O(n)
# 공간복잡도: O(n)

점화식: dp[i] = dp[i-1] + dp[i-2]


2. 배낭 문제 (0/1 Knapsack)

문제: 무게와 가치가 있는 물건들을 배낭에 넣어 가치 최대화

def knapsack(weights, values, capacity):
    n = len(weights)
    # dp[i][w]: i번째까지 물건, 무게 w일 때 최대 가치
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        for w in range(capacity + 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])
 
    return dp[n][capacity]
 
# 테스트
weights = [2, 1, 3, 2]
values = [12, 10, 20, 15]
print(knapsack(weights, values, 5))  # 37
 
# 시간복잡도: O(n × capacity)
# 공간복잡도: O(n × capacity)

핵심: 각 물건을 넣을지 말지 결정


3. 최장 증가 부분 수열 (LIS)

문제: 주어진 수열에서 증가하는 가장 긴 부분 수열의 길이

def longest_increasing_subsequence(nums):
    if not nums:
        return 0
 
    n = len(nums)
    dp = [1] * n  # dp[i]: i를 마지막으로 하는 LIS 길이
 
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
 
    return max(dp)
 
# 테스트
print(longest_increasing_subsequence([10, 9, 2, 5, 3, 7, 101, 18]))  # 4
# LIS: [2, 3, 7, 101] 또는 [2, 3, 7, 18]
 
# 시간복잡도: O(n²)
# 공간복잡도: O(n)

점화식: dp[i] = max(dp[j] + 1) (j < i이고 nums[j] < nums[i])


4. 최장 공통 부분 수열 (LCS)

문제: 두 문자열의 최장 공통 부분 수열 길이

def longest_common_subsequence(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
 
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[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]
 
# 테스트
print(longest_common_subsequence("abcde", "ace"))  # 3
 
# 시간복잡도: O(m × n)
# 공간복잡도: O(m × n)

점화식:

  • text1[i] == text2[j]: dp[i][j] = dp[i-1][j-1] + 1
  • 다르면: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

5. 편집 거리 (Edit Distance)

문제: 문자열을 변환하는데 필요한 최소 연산 횟수

def edit_distance(word1, word2):
    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
 
    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]
 
# 테스트
print(edit_distance("horse", "ros"))  # 3
 
# 시간복잡도: O(m × n)
# 공간복잡도: O(m × n)

핵심: 삽입, 삭제, 교체 연산 중 최소 선택


6. 동전 교환 (Coin Change)

문제: 동전으로 금액을 만드는 최소 개수

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
 
    for coin in coins:
        for i in range(coin, amount + 1):
            dp[i] = min(dp[i], dp[i - coin] + 1)
 
    return dp[amount] if dp[amount] != float('inf') else -1
 
# 테스트
print(coin_change([1, 2, 5], 11))  # 3 (5+5+1)
 
# 시간복잡도: O(amount × len(coins))
# 공간복잡도: O(amount)

점화식: dp[i] = min(dp[i], dp[i-coin] + 1)


추천 연습 문제

기초

중급

고급


핵심 요약

개념설명
원리중복 부분 문제의 결과 저장
방법메모이제이션, 타뷸레이션
시간복잡도지수 → 다항 시간으로 개선
활용최적화 문제, 경우의 수

DP 문제 해결 템플릿

1. 1차원 DP

def dp_1d(n):
    dp = [0] * (n + 1)
 
    # 초기값 설정
    dp[0] = base_case
 
    # 점화식 적용
    for i in range(1, n + 1):
        dp[i] = f(dp[i-1], dp[i-2], ...)
 
    return dp[n]

2. 2차원 DP

def dp_2d(n, m):
    dp = [[0] * (m + 1) for _ in range(n + 1)]
 
    # 초기값 설정
    for i in range(n + 1):
        dp[i][0] = base_case
 
    # 점화식 적용
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
 
    return dp[n][m]

3. 공간 최적화

def dp_optimized(n):
    # 이전 값만 필요한 경우
    prev = base_case
 
    for i in range(1, n + 1):
        current = f(prev)
        prev = current
 
    return current

DP vs 그리디 vs 분할정복

특성DP그리디분할정복
접근모든 경우 고려현재 최선 선택독립적 부분문제
최적해보장보장 안 됨보장
중복중복 있음없음없음
예시배낭, LCS동전 거스름돈병합 정렬

주의사항

1. 초기값 설정

# 최댓값 문제
dp = [0] * n
 
# 최솟값 문제
dp = [float('inf')] * n
dp[0] = 0

2. 인덱스 범위

# 1-based vs 0-based 주의
dp = [0] * (n + 1)  # n까지 사용

3. 메모리 초과

# 2차원 → 1차원으로 최적화
# dp[i][j] → dp[j] (이전 행만 필요한 경우)

4. 점화식 도출

# 1. 작은 예제로 패턴 찾기
# 2. 이전 상태와의 관계 파악
# 3. 점화식 작성
# 4. 초기값 설정

DP 최적화 기법

1. 슬라이딩 윈도우

# 일정 범위만 유지
for i in range(n):
    # 최근 k개만 사용
    window = dp[max(0, i-k):i+1]

2. 비트마스킹 DP

# 상태를 비트로 표현
dp = [0] * (1 << n)

3. 분할 정복 최적화

# Convex Hull Trick, Divide and Conquer Optimization

DP 문제 풀이 단계

  1. DP 적용 가능 확인

    • 최적 부분 구조
    • 중복되는 부분 문제
  2. 상태 정의

    • dp[i]: i일 때의 최적값
    • dp[i][j]: i, j일 때의 최적값
  3. 점화식 도출

    • 이전 상태로부터 현재 상태 계산
  4. 초기값 설정

    • Base case 정의
  5. 구현 및 최적화

    • 메모이제이션 or 타뷸레이션
    • 공간 최적화 고려