[알고리즘] 06. Dynamic Programming (Part 2)

jmt·2024년 4월 9일

알고리즘

목록 보기
7/18

Matrix-Chain Multiplication

두 행렬의 곱은 다음과 같은 수도 코드로 계산된다는 것은 당연한 사실이다.

그런데 만약 행렬이 여러 개인 경우, 최적의 연산 횟수는 어떻게 될까? 두 행렬을 곱하는 연산 횟수를 단순히 곱한 것으로 동일할까? 만약 행렬의 크기가 제각각이라면 그렇지 않다.

만약 nn개의 행렬이 담겨 있는 sequence를 가정해보자. 곱하는 순서를 괄호로 묶어 표시한다면, 연산 순서를 나타내는 방법은 총 몇가지가 있을까? 예를 들어 n=4n=4라면, sequence에는 A1,A2,A3,A4\langle A_1, A_2, A_3, A_4\rangle가 있게 된다. 이 때 괄호로 묶어 연산 순서를 지정하는 방법은 총 5가지이다.

(((A1,A2),A3),A4);(A1,(A2,(A3,A4)));((A1,(A2,A3)),A4);(A1,((A2,A3),A4));((A1,A2),(A3,A4)).(((A_1, A_2), A_3), A_4);\quad (A_1, (A_2, (A_3, A_4)));\\ ((A_1,( A_2, A_3)), A_4);\quad (A_1, ((A_2, A_3), A_4));\quad ((A_1, A_2), (A_3, A_4)).

이렇게 연산 순서를 구분하는 방법에 따라 실제로 연산 횟수가 달라질까? 아래의 예시를 살펴보자.

연산 횟수가 달라지는 이유

sequence는 A1,A2,A3\langle A_1, A_2, A_3\rangle이 존재한다고 가정하자. 각 행렬의 크기는 10×100,100×5,5×5010\times100, 100\times5, 5\times50이다. 이 때, 연산 순서를 구분하는 방법은 2가지이다.

  1. ((A1,A2),A3)((A_1, A_2), A_3)
  2. (A1,(A2,A3))(A_1, (A_2, A_3))

1번 경우일 때, A1,A2A_1, A_2의 행렬 곱을 먼저하기 때문에, 연산 횟수는 10×100×5=500010\times100\times5=5000이고, 그 다음 A3A_3과 곱하게 되면, 10×5×50=250010\times 5 \times 50=2500번 연산하게 되어 총 75007500의 연산 횟수가 필요하다.

2번 경우일 때는 A2,A3A_2, A_3의 행렬 곱을 먼저 계산하면, 2500025000번 + 그 후 A1A_1 행렬과 곱하면 5000050000번의 연산이 수행되어 총 7500075000의 연산 횟수가 필요하다.

즉, 1번 경우가 2번 경우보다 무려 10배나 더 빠르다. 이처럼 연속된 행렬의 곱셈을 수행할 때 연산 횟수가 가장 적게 만드는 방법을 찾는 문제를 Matrix-Chain Multiplication Problem이라고 한다.

Brute-Force Algorithm

완전 탐색하는 경우를 가정하면, P(n)P(n)nn개의 길이를 갖는 연속된 행렬의 곱을 계산 순서를 정하기 위해 괄호로 묶는 서로 다른 방법의 수라고 정의하자. 그럼 brute-force의 P(n)P(n)은 다음과 같다.

P(n)={1if n=1k=1n1P(k)P(nk)if n2P(n) = \begin{cases} 1 &\text{if }n=1\\ \sum^{n-1}_{k=1} P(k)P(n-k) &\text{if }n\ge 2 \end{cases}

시간복잡도는 어떻게 될까? 우선 nn을 5까지 대입해보며 차례차례 구해보자. 그럼 P(2)=1,P(3)=2,P(4)=5,P(5)=14P(2)=1, P(3)=2, P(4)=5, P(5)=14로 나오게 되는데, substitution method로 구해보자. nn을 대입해보며 구한 값으로 우리는 다음과 같이 유추할 수 있다.

P(n)2n2 for 1n5P(n)2n2P(n) \ge 2^{n-2} \text{ for }1\le n \le 5 \Rightarrow P(n) \ge 2^{n-2}

n=1일 때, 가정한 식에 그대로 대입하면 P(1)=1P(1)=1이므로 참이다. 그 후 k>5k>5에 대해 P(k)2k2P(k) \ge 2^{k-2}가 참인지 확인하면 옯은 가정이라 할 수 있다.

P(n)=k=1n12k22nk2=k=1n12n4=(n1)(2n4)=n142n2\begin{aligned} P(n) &= \sum^{n-1}_{k=1}2^{k-2} \cdot 2^{n-k-2} \\ &= \sum^{n-1}_{k=1} 2^{n-4}\\ &= (n-1)(2^{n-4})\\ &= \frac{n-1}{4}\cdot 2^{n-2} \end{aligned}

그 후, P(n)2n2P(n) \ge 2^{n-2}인지 확인해보면,

n142n22n2n5.\frac{n-1}{4} \cdot 2^{n-2} \ge 2^{n-2} \Rightarrow n \ge 5.

따라서 P(n)Ω(2n)P(n) \in \Omega(2^n)임이 증명되었다. 어찌됐든 완전 탐색으로 문제를 풀면, 시간 복잡도는 polynomial expression이 아니라 exponential한 bound를 가지게 되기 때문에 비효율적이라는 것은 알 수 있다.

Applying Dynamic Programming

이번에는 DP 기법을 적용해보고, 시간 복잡도가 얼마나 차이나는지 알아보자. 우선 4개의 단계로 DP 기법을 문제에 적용할 수 있다고 하였다.

  1. 최적해의 구조 특징 파악 = optimal substructure가 있는 지 확인!
  2. 최적해의 값을 구하기 위해 재귀적으로 호출. = 어떻게 호출할 것인지 정의
  3. 각 재귀 호출마다 값을 계산
  4. 계산된 값들로 최적해를 형성

Step 1 :

우선 DP 기법으로 문제를 풀기 위해서는 optimal substructure가 있어야 한다. 문제 구조에 최적 부분구조가 존재하는지 어떻게 확인할 수 있을까? 그 증명은 다음과 같다.

Proof by Contradiction

만약, 다음과 같이 묶는 경우가 최적의 경우라고 가정하자.

((A1,A2,,Ai)(Ai+1,,An))((A_1, A_2, \cdots, A_i) (A_{i+1}, \cdots, A_{n}))

간단히 표기하기 위해 괄호로 묶은 항 중에 첫번째 항을 A1...iA_{1...i}라 하고, 두번째 항을 Ai+1...nA_{i+1...n}이라고 하자. 이 때가 최적의 해라고 가정했기 때문에, 연산 횟수의 총 합은 무조건 최소값이 된다. 각 연산 횟수를 다음과 같이 정의하자.

  1. A1...iA_{1...i}의 행렬 곱을 계산하는 연산 횟수를 XX라 하고,
  2. Ai+1...nA_{i+1...n}의 행렬 곱을 계산하는 연산 횟수를 YY,
  3. 마지막 두 행렬의 연산 횟수를 ZZ라 정의하자.

그럼 X+Y+ZX+Y+Z가 최소값인데, 만약 A1...iA_{1...i}의 경우가 최적의 경우가 아니라면, X<XX^{\prime} < XXX^{\prime}이 존재하게 된다. 이 말은 곧, X+Y+ZX^{\prime}+Y+Z가 최소값이기 때문에 모순이 발생한다. 결론적으로 ii를 기준으로 나눈 것이 최적의 구조라면, 총 연산 횟수도 자동적으로 최적의 해가 된다.

즉, subproblem의 optimal solutions로 부터 원래 문제의 optimal solution을 도출할 수 있다는 뜻이고, 이는 문제가 optiaml substructure를 가진다는 의미이기에 DP 기법을 적용할 수 있다.

Step 2

이제 재귀적으로 어떻게 호출할 것인지 결정하자. 어떤 행렬 AiA_i의 크기를 pi1×pip_{i-1} \times p_i라 정의하자. 그리고 DP 기법을 적용하기 위해 이전 연산 결과를 저장하는 배열 mm을 정의하고, m[i,j]m[i, j]에는 Ai...jA_{i...j}의 최적 연산 횟수(최소 연산 횟수)를 저장한다고 하자. 만약 k,(ikj)k, (i\le k\le j)로 나눌 때가 최적이라면, Ai...jA_{i...j}의 최소 연산 횟수(m[i,j]m[i, j]에 저장되는 값)는 다음과 같이 정의된다.

m[i,j]=m[i,k]+m[k+1,j]+pi1pkpjm[i, j] = m[i, k] + m[k+1, j] + p_{i-1}\cdot p_k \cdot p_j

만약, i=ji=j의 경우에는 배열 mm에 0이 저장된다.

그리고 추가적으로 배열 ss를 정의하여, s[i,j]s[i, j]에는 최적으로 나눈 kk값을 저장한다고 하자.

Step 3

Step 2에서 정의한 재귀적인 호출 방법에 의해 계산하는 수도 코드와 배열 mm의 값은 다음과 같다.

위의 예시는 n=6n=6인 경우이다. 각 행렬의 크기는 다음과 같다.

Step 4

n=6n=6일 때, input에 대하여 계산된 결과는 다음과 같다.

이를 활용해 최적의 해를 구하는 수도 코드는 다음과 같다.

그럼 답은

PRINT-OPTIMAL-PARENS(s, 1, 6)

을 실행한 결과로,

()(()())(A1()()A6)(A1(A2A3)(A4A5)A6)() \rightarrow (()())\rightarrow(A_1()()A_6)\rightarrow(A_1(A_2A_3)(A_4A_5)A_6)

이 출력될 것이다. 시간 복잡도는 Step 3의 수도 코드를 보면 알 수 있듯이 반복문을 3번 돌기 때문에 O(n3)O(n^3)이라 예상할 수 있다.

Prove of O(n3)O(n^3)

직관적으로 3중 반복문을 보는 순간, 우리는 O(n3)O(n^3)이라 예상할 수 있지만 실제로 그런지 증명해보자. 배열 mm에 접근하는 횟수를 연산 횟수라고 가정하자. DP 기법으로 Matrix chain multiplication problem을 푸는 수도 코드는 위에서 정의했었고, 그럼 총 연산 횟수는 R(i,j)R(i, j)이다.

우선 첫번째 반복문에서 변수 ll이 2에서 nn까지 돌게 되면서 n1n-1번 반복한다는 사실을 알 수 있다.
두번째 변수 ii에 대한 for문은 nl+1n-l+1번 반복한다. 세번째 변수 kkjij-i번 반복하는데, 여기서 j=i+l1j=i+l-1이므로 반복 횟수는 l1l-1번이다. 다만, 이 때 두 번 배열 mm에 접근하기 때문에 2를 곱해준다. 그럼 최종 연산 횟수는

i=1nj=1nR(i,j)=l=2n(nl+1)(l1)2=l=1n1(nl)l2=2l=1n1(nl)l=2[l=1n1nll=1n1l]=2[nn(n1)2n(n1)(2n1)6]=n3n223n3+n213n=13n313n=n3n3\begin{aligned} \sum^n_{i=1}\sum^n_{j=1}R(i, j)&=\sum^n_{l=2} (n-l+1)(l-1)\cdot2 \\ &=\sum^{n-1}_{l=1} (n-l)\cdot l \cdot 2\\ &=2\sum^{n-1}_{l=1}(n-l)\cdot l \\ &=2\left[\sum^{n-1}_{l=1}nl- \sum^{n-1}_{l=1}l\right]\\ &=2\left[n \cdot \frac{n(n-1)}{2} - \frac{n(n-1)(2n-1)}{6} \right]\\ &= n^3 - n^2 - \frac{2}{3}n^3 + n^2 - \frac{1}{3}n\\ &= \frac{1}{3}n^3 - \frac{1}{3}n \\ &= \frac{n^3 - n}{3} \end{aligned}

그러므로 T(n)=n3n3O(n3)T(n)=\frac{n^3 - n}{3} \in O(n^3)임이 증명되었다.

profile
고분자/컴공

0개의 댓글