행렬 멱법이 무엇인지 모르신다면 여기로
이번 포스팅에서는 행렬 멱법을 이용해 $n$ 번째 피보나치 값을 구하는 방법에 대해 말해보고자 합니다.
피보나치 숫자
피보나치 숫자들은 다들 아시다시피 다음과 같은 정수 나열이죠.
$$ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, \dotsc $$
수식으로 적자면 $F_n$ 는 $n$ 번째 피보나치 숫자라고 정의하겠습니다. 그렇다면,
$$F_n = F_{n-1} + F_{n-2}$$
이 되고, 초기값은
$$F_0 = 0, F_1 = 1$$
으로 정합니다.
숫자 $n$ 이 정해지면 $n$번째 피보나치 숫자를 출력합니다.
입력값 : n = 2
출력값 : 1
입력값 : n = 9
출력값 : 34
어떤 방법들이 있을까요?
총 4개의 방법이 있습니다.
첫번째로는 단순 접근 방법은 위 수식을 활용한 재귀 함수를 작성하는 것입니다. 지수 형태의 시간복잡도가 걸립니다.
두번째로는 동적 계획법을 이용하는 방법이 있습니다. 이 방법은 $O(n)$의 시간복잡도가 필요합니다.
여기서는 세번째와 네번째 방법에 집중하려 합니다.
세번째 방법은 행렬 {{1,1},{1,0}} 의 멱수를 이용하는 것입니다. 다시 말해서 행렬 멱법을 사용하는 것입니다. $F_n$ 이 $n$ 번째 피보나치 숫자라고 한다면,
$$ {\begin {bmatrix} 1 & 1 \cr 1 & 0 \end {bmatrix}}^{n} = \begin {bmatrix} F_{n+1} & F_{n} \cr F_{n} & F_{n-1} \end {bmatrix}$$
가 성립합니다. 다음은 위 수식을 이용한 코드 입니다.
#include <stdio.h>
void multiply(int F[2][2], int M[2][2]);
void power(int F[2][2], int n);
/* n 번째 피보나치 숫자를 반환하는 함수 */
int fib(int n)
{
int F[2][2] = {{1,1},{1,0}};
if (n == 0)
return 0;
power(F, n-1);
return F[0][0];
}
/* 행렬의 멱수를 취하는 함수, 최적화 시킴 */
void power(int F[2][2], int n)
{
if( n == 0 || n == 1)
return;
int M[2][2] = {{1,1},{1,0}};
power(F, n/2);
multiply(F, F);
if (n%2 != 0)
multiply(F, M);
}
/* 행렬의 곱을 계산하는 함수, 결과값은 다시 F 에 저장됩니다. */
void multiply(int F[2][2], int M[2][2])
{
int x = F[0][0]*M[0][0] + F[0][1]*M[1][0];
int y = F[0][0]*M[0][1] + F[0][1]*M[1][1];
int z = F[1][0]*M[0][0] + F[1][1]*M[1][0];
int w = F[1][0]*M[0][1] + F[1][1]*M[1][1];
F[0][0] = x;
F[0][1] = y;
F[1][0] = z;
F[1][1] = w;
}
/* 테스트를 위한 드라이버 프로그램 */
int main()
{
int n = 9;
printf("%d", fib(9));
getchar();
return 0;
}
위의 행렬의 멱수를 구하는 함수는 n
이 짝수냐 홀수냐에 따라서 행렬 멱수의 계산방법이 조금 다릅니다. 단순히 행렬 F
를 n
승 하는것보다 훨씬 최적화된 방법입니다. 왜냐하면 시간복잡도가 크게 줄어들기 때문입니다. $O(n)$ 에서 $O(log(n))$ 으로요.
네번째 방법은 세번째 방법의 응용이지만 더 유용하게 쓰일 수 있습니다. 일단 이 수식으로 부터 시작하겠습니다.
$$ {\begin{bmatrix} 1 & 1 \cr 1 & 0 \end{bmatrix}}^{n} = \begin {bmatrix} F_{n+1} & F_{n} \cr F_{n} & F_{n-1} \end{bmatrix}$$
양쪽에 있는 행렬의 determinant(행렬식)을 구합니다. 그러면, $$ (-1)^n = F_{n+1}F_{n-1} - F_{n}^{2}$$ 가 됩니다.
사각 행렬 $A$ 가 존재한다면, $A^nA^m = A^{n+m}$ 으로 계산될 수 있기에,
$$ {\begin{bmatrix} 1 & 1 \cr 1 & 0 \end{bmatrix}}^n \times {\begin{bmatrix} 1 & 1 \cr 1 & 0 \end{bmatrix}}^m = \begin{bmatrix} F_{n+1} & F_{n} \cr F_{n} & F_{n-1} \end{bmatrix} \times \begin{bmatrix} F_{m+1} & F_{m} \cr F_{m} & F_{m-1} \end{bmatrix} $$
왼쪽의 {{1,1},{1,0}} 행렬은 다음과 같이 합쳐집니다.
$$ {\begin{bmatrix} 1 & 1 \cr 1 & 0 \end{bmatrix}}^{n+m} = \begin{bmatrix} F_{n+m+1} & F_{n+m} \cr F_{n+m} & F_{n+m-1} \end{bmatrix} = \begin{bmatrix} F_{n+1} & F_{n} \cr F_{n} & F_{n-1} \end{bmatrix} \times \begin{bmatrix} F_{m+1} & F_{m} \cr F_{m} & F_{m-1} \end{bmatrix} $$
위의 수식은 다음과 같이 분기될 수 있습니다.
이제, $F_{n+m-1}$ 를 활용해 보도록 하겠습니다.
$$ F_mF_n +F_{m-1}F_{n-1} = F_{m+n-1} \cdots (1)$$
여기서 $n = n+1$ 를 하면,
$$ F_mF_{n+1} +F_{m-1}F_{n} = F_{m+n} \cdots (2)$$
이 됩니다.
다시 $(1), (2) $ 에 $m = n$ 를 해보겠습니다.
$$ F_{2n-1} = F_{n}^{2} + F_{n-1}^{2}$$
$$ F_{2n} = (F_{n-1} + F_{n+1})F_{n} = (2F_{n-1} + F_{n})F_{n} \qquad \because F_{n+1} = F_{n} + F_{n-1}$$
이 됩니다.
이제는 따로 행렬을 계산할 필요 없이, 위의 수식만을 가지고, $n$ 이 짝수이냐 홀수이냐에 따라서 피보나치 $n$ 값을 정할 수 있게되었습니다.
예를 들어 $n$ 이 $5$ 이면, $ k = 2n-1$ 자리에다가 $n = 3 = (5 +1) /2 $ 를 넣어주면 $k = 5$ 가 나옵니다.
같은 방법으로 $n$ 이 $4$ 이면, $ k = 2n$ 자리에다가 $n = 2= 4 /2 $ 를 넣어주면 $k = 4$ 가 나옵니다.
그러므로 아래와 같은 방법을 따라 적절한 $k$ 를 찾고 위의 수식에 대입만 하면 될거 같습니다.
$n$ 이 짝수면, $k = n/2$
$n$ 이 홀수면, $k = (n+1)/2$
# n 번째 피보나치 숫자를 찾기 위한 python 3 프로그램
# O (Log n) 의 시간 복잡도를 가지고 있음.
MAX = 1000
# memoization 을 위한 배열 생성
f = [0] * MAX
# f 배열을 이용해 n 번째 피보나치 숫자를 반환하는 함수
def fib(n) :
# Base cases
if (n == 0) :
return 0
if (n == 1 or n == 2) :
f[n] = 1
return (f[n])
# 이미 fib(n) 이 계산 되있다면 그 값을 반환
if (f[n]) :
return f[n]
if( n & 1) :
k = (n + 1) // 2
else :
k = n // 2
# 위에서 논의한 수식을 적용합니다 .
# 참고로 n & 1 의 값이 1일땐 n 이 짝수란 의미
# n 이 0 이면 n 은 홀수
if((n & 1) ) :
f[n] = (fib(k) * fib(k) + fib(k-1) * fib(k-1))
else :
f[n] = (2*fib(k-1) + fib(k))*fib(k)
return f[n]
# 드라이버 코드
n = 9
print(fib(n))
# 이 코드는 Nikita Tiwari 에 의해 작성되었습니다.
이 수식을 이용한 피보나치 값을 구하는 솔루션의 시간복잡도는 $O(log(n))$ 입니다.
행렬 멱법을 이용한 솔루션과 시간이 같네요. 하지만 중요한 점은 프로그래밍 문제를 풀때는 네번째 방법이 굉장히 유용하게 작용됩니다. 왜냐하면 행렬 멱법은 power(F, n)
함수를 이용하는데, 이는 재귀적 성질을 가지고 있어서 n
이 큰값이 나오게 된다면 (약 $10^{18}$ 정도?), 무자비하게 자기 자신의 함수를 불러내고 또 불러낼것입니다. 이렇게 되면 메모리상에 스택이 꽉 차게 되서 stack overflow error 를 불러 일으키거나 아니면 재귀를 거치는 과정에서 마저 시간이 소요되기 때문에 시간 초과 에러 (TLE) 가 나타날 수 있습니다. 네번째 방식 역시 재귀적 방식을 사용했지만, tabulation 을 이용하기 때문에 함수 호출양을 절반으로 줄였으니 시간적, 공간적으로 큰 절약을 가져옵니다.
사실 이것은 DP 문제를 풀기위해서 공부하다보니 이 정보까지 포스팅.. 이럴수가
'프로그래밍' 카테고리의 다른 글
[DP] 도시의 집 (0) | 2018.02.20 |
---|---|
segment tree (세그먼트 트리) (0) | 2018.02.19 |
Matrix Exponentiation (행렬 멱법) (1) | 2018.02.17 |
[DP] 게임 이기기 (0) | 2018.02.17 |
python List 조작법 : Extended Slices (0) | 2018.02.17 |