Dynamic Programming

youngwiki
Pinkgo (토론 | 기여)님의 2025년 11월 15일 (토) 03:24 판 (Fibonacci Numbers)

상위 문서: 알고리즘 설계와 분석

개요

동적 프로그래밍(Dynamic Programming)은 좌우 순서로 배열된 문제들[1]에 대한 최적화 문제를 효율적으로 푸는 방법이다. 동적 프로그래밍의 핵심적인 아이디어는 복잡한 문제를 작은 하위 문제(subproblem) 로 나누고, 중복 계산을 피하기 위해 이전 결과를 저장(memoization)하는 것이다. 이때 동적 프로그래밍은 재귀적으로 구현한다는 것을 의미하지 않는다. 동적 프로그램의 본질은 어디까지나:

  1. 중복되는 하위 문제(Overlapping Subproblems): 예를 들면, 피보나치에서 fib(3)을 여러 번 계산하지 않는 것이 있다.
  2. 최적 부분 구조(Optimal Substructure): 큰 문제의 최적해가 작은 문제의 최적해들로 구성되는 것이다.

따라서 DP를 구현하는 방식은 아래와 같이 두 가지로 나뉜다:

  1. Top-Down(Memoization): 큰 문제에서 출발해 재귀를 통해 하위 문제를 호출하며, 이미 계산된 값은 저장하여 활용한다.
  2. Bottom-Up(Tabulation): 가장 작은 하위 문제부터 순서대로 반복문으로 해결하며, 결과를 테이블에 저장한다.

동적 프로그래밍은 완전탐색(Exhaustive Search) 방식에 같이 사용된다. 완전 탐색 기법은 모든 가능한 해를 탐색하므로 항상 정답은 구하지만 비효율적이지만 동적프로그램과 같이 응용하여, 중복 계산을 제거하여 효율을 높일 수 있다. 이를 이용한 대표적인 알고리즘으로는 Floyd’s Algorithm이 있다.

Recurrence Relations

점화식(Recurrence Relation)은 어떤 수열을 “이전 항들로 정의하는 식”이다. 즉, "자기 자신을 기반으로 정의된 함수"이다. 예를 들면, 아래와 같은 식이 있다:

an=an1+1,a1=1an=n
an=2an1,a1=2an=2n
an=nan1,a1=1an=n!

이와 같이 점화식을 통해 문제의 구조를 반복되는 관계로 표현함으로서 프로그램이 재귀적으로 계산할 수 있다. 즉, 동적 프로그래밍은 이러한 점화식을 효율적으로 계산하기 위해 부분 결과를 저장하는 기법이다.

Master Theorem

이때 점화식은 문제를 작은 하위 문제로 나누었을 때 이를 표현하는 수식이므로, 주어진 점화식을 이용해 알고리즘의 시간복잡도를 추론할 수 있다. 이를 마스터 정리(master theorem)이라고 하며, 이는 분할 정복 알고리즘에도 적용될 수 있다. 마스터 정리는 점화식이 아래와 같은 형태임을 가정한다:

T(n)=aT(n/b)+f(n)

위에서 a는 분할할 때 문제의 개수, n/b는 분할된 각 문제의 크기, f(n)은 부분 문제의 해를 합치는데 걸리는 비용을 의미한다. 일반화한 이 식을 통해 T(n)의 점근적인 시간복잡도를 구하면 아래와 같다:

  • Case 1: f(n)=O(nlogbaϵ), for some ϵ>0
    • 즉, 합치는 비용이 부분 문제 비용보다 작다. 이 경우, T(n)=Θ(nlogba)
  • Case 2: f(n)=Θ(nlogba),
    • 즉, 합치는 비용이 부분 문제 비용과 비슷하다. 이 경우, T(n)=Θ(nlogbalog(n))
  • Case 3: f(n)=Ω(nlogba+ϵ), for some ϵ>0
    • 즉, 합치는 비용이 부분 문제 비용보다 크다. 이 경우, T(n)=Θ(f(n))
    • 단, 정규성 조건이 필요하다: af(n/b)cf(n) for some c<1

Three Steps to Dynamic Programming

마스터 정리를 바탕으로, 동적 프로그래밍을 하기 위한 3단계를 요약하면 아래와 같다:

  1. 점화식 세우기: 문제를 작은 하위 문제로 나누고, 이들 결과를 결합하는 수식을 만든다.
  2. 서브문제 개수의 다항성 보장: 가능한 하위 문제 수가 너무 많지 않아야 한다.[2]
  3. 계산 순서 결정: 하위 문제의 결과를 이미 계산된 값으로부터 효율적으로 구할 수 있도록 순서를 정한다.

Fibonacci Numbers

피보나치 수열(Fibonacci Numbers)은 동적 프로그래밍의 대표적인 구현 예시 중 하나이다.

피보나치 수열에 대한 자세한 설명은 해당 문서를 참조해주십시오.

Binomial Coefficients

이항 계수(Binomial Coefficients)는 동적 프로그래밍의 고전적인 응용 중 하나이다. 이항 계수는 수학적으로 아래와 같이 정의된다:

(nk)= "n개 중에서 k개를 선택하는 방법의 수"

이항 계수는 흔히 조합(combination)이라고 불리며, 다양한 분야에 활용된다. 예를 들어 n명의 사람 중 k명을 뽑는 경우의 수이나, n×m 격자의 왼쪽 위에서 오른쪽 아래까지, 오른쪽 또는 아래로만 이동할 수 있을 때 가능한 경로의 수[3]를 구할 때 사용된다. 이 외에도 경로 문제, 선택 문제, 확률 계산 등 매우 다양한 문제에 등장한다.

이항 계수는 동적 프로그래밍의 전형적인 예시 중 하나이다. 왜냐하면 이항 계수는 반복되는 하위 문제 구조를 갖고 있기 때문이다. 이항 계수는 아래와 같이 계산된다:

(nk)=n!(nk)!k!

즉, 팩토리얼을 이용하여 직접 계산할 수 있다. 하지만 이는 중간 계산에서 오버플로(overflow)가 발생한 우려가 있다. 예를 들어, 20!=2,432,902,008,176,640,000이므로 중간 계산이 너무 커져서 자료형이 감당하지 못한다. 따라서 직접 팩토리얼 계산보다는 점화식을 이용하는 방법이 더 안전하고 효율적이다.

Pascal’s Triangle

파스칼의 삼각형은 figure 2와 같은 형태를 가지고 있는 수의 배치인데, 이항 계수를 삼각형 모양으로 배열한 것이다. 파스칼의 삼각형의 배치 규칙은 그 바로 위의 두 수의 합이다. 이는 아래와 같은 관계를 시각적으로 표현한 것이다:

>(nk)=(n1k1)+(n1k)

바로 위 식의 파스칼의 점화식이다. 이를 직관적으로 표현하면, n번째 원소를 포함하는 경우의 수 (nk1)[4]와 n번째 원소를 포함하지 않는 경우의 수 (nk1)[5]를 합치면 n개의 원소 중에서 k개를 선택하는 경우의 수가 된다는 것이다. 이 방식의 장점은 팩토리얼 계산이 필요 없으며, 단순 덧셈 연산으로 해결 가능하므로 동적 프로그래밍 적용에 매우 적합하다는 것이다.

이를 활용하여, 이항 관계에 대한 기저 조건(base case)을 분석하면 아래와 같다:

  1. (n0)=1: 아무 것도 고르지 않는 경우는 항상 1가지 (공집합)
  2. (nn)=1: 모든 원소를 고르는 경우도 항상 1가지 (전체 집합)

즉, 이항 계수에 대한 점화식은 아래와 같이 구해진다:

(nk)={1,ifk=0ork=n(n1k1)+(n1k),otherwise

Binomial Coefficients Implementation

아래는 위의 점화식을 C코드로 표현한 것이다:

long binomial_coefficient(int n, int k) {
    int i, j;
    long bc[MAXN+1][MAXN+1];

    // base cases
    for (i = 0; i <= n; i++)
        bc[i][0] = 1;
    for (j = 0; j <= n; j++)
        bc[j][j] = 1;

    // fill DP table using Pascal’s recurrence
    for (i = 2; i <= n; i++) {
        for (j = 1; j < i; j++) {
            bc[i][j] = bc[i-1][j-1] + bc[i-1][j];
        }
    }

    return bc[n][k];
}

The Gas Station Problem

Gas Station Problem은 아래와 같은 문제 정의를 가진다:

가정 1: 뉴욕에서 플로리다까지 가는 길에 주유소 g1,g2,,gn이 있다.
가정 2: 각 주요소는 mile marker mi에 위치해 있으며, 자동차는 한 번 주유로 R마일을 달릴 수 있다.
목표: 목적지까지 가기 위해 최소 몇 번 주유해야 하는가?

이때 위 문제를 해결하기 위해서 G[i]를 주유소 gi에 도달하기 위해 필요한 최소 주유 횟수라고 하면 점화식은 아래와 같이 정의된다:

G[1]=0[6]
G[i]=minj<i,(mimj<R)(G[i]+1)

이는 이전 주유소 중에서 i까지 한 번에 갈 수 있는 모든 j에 대해, 가장 적은 주유 횟수의 경우를 선택하고 한 번 추가한다는 것을 의미한다.

이에 대한 시간복잡도를 계산하면 O(n2)이다. 이때 모든 i에 대해 가능한 j를 확인하므로 이 문제는 BFS로도 해결할 수 있다. 각 주유소를 정점, 도달 가능한 관계를 간선으로 보면 unweighted shortest path 문제이기 때문이다. 사실 많은 DP 문제는 실제로 DAG(Directed Acyclic Graph)에서 최단 경로를 찾는 문제의 변형이다.

Minimum Average Station Price

이번에는 문제를 변형하여, 각 주요소마다 단가 p(s)가 다르다고 가정하여 “평균 가격이 최소가 되도록 어느 주유소들에서 멈춰야 하는가?”를 구해야 한다고 가정하자. 이때에는 얼마나 자주 넣는가를 고려하지 않고 방문하는 주유소들의 주유비 평균만 최소화하면 된다고 하자. 이를 해결하기 위해 G[i,k]를 주유소 gi에 정확히 k번 주유한 뒤 도달할 때의 총 단가 합계라고 하면, 아래와 같이 점화식이 정의된다:

G[1,0]=0
G[i,k]=minj<i,(mimj<R)(G[j,k1]+p(j))

즉, 해당 점화식은 이전 주유소 j에서 k-1번째 주유까지의 최소 단가합과 이번에 방문하는 주유소의 단가의 합을 의미한다. 최종적으로는 아래와 같은 식을 통해 평균 주유비가 최소가 되는 k를 찾는다:

minkG[n,k]k

하지만 이 방식은 “싼 곳에서 많이 넣고 비싼 곳에서 덜 넣는” 실제 전략을 반영하지는 못한다.

Cheapest Filling Schedule

해당 문단에서는 위 문제를 더욱 현실적으로 변형하여, 한 번에 “정수 갤런” 단위로만 주유가 가능하며, 각 주유소마다 가격이 다를 때, “최소 총비용으로 목적지까지 가는 주유 스케줄은 무엇인가?”를 구해야 한다고 가정하자. 이를 해결하기 위해 G[i,k]를 주유소 gi에 도착했을 때 k갤런의 기름이 남은 상태로의 최소 비용을 의미한다고 가정하자. 이를 이용하면 점화식은 아래와 같이 구해진다:

G[1,0]=0 
G[i,k]=mincminj<i,(mimj<Rc){G[j,c]+p(j)(kc)}

위에서 c은 출발 시 남은 기름량이며, p(j)는 j번째 주유소의 단가를 의미한다. 또한 (mimj<Rc)은 주유 후 남은 연료로 i까지 도달 가능한 경우만 고려한다는 것을 의미한다.

각주

  1. 문자열, 수열 등에 관한 문제이다.
  2. 마스터 정리를 통해 계산했을 때 전체 알고리즘의 시간 복잡도가 O(n2),O(n3) 정도이면 적당하다.
  3. (n+mn)이다.
  4. 이미 n을 포함하므로, k-1개만 더 선택하면 된다.
  5. 이미 n을 포함하므로, k-1개만 더 선택하면 된다.
  6. 첫 번째 주유소는 출발점이므로 주유 횟수가 0이다.