카라츠바 알고리즘 (Karatsuba algorithm)
Updated:
카라츠바 알고리즘 이해
카라츠바 알고리즘 (Karatsuba algorithm)은 큰 수에 대한 효과적인 알고리즘이다. 이 방법은 두 $n$자리 수 곱셈을 $3n^{log_2{3}}=3n^{1.585}$개의 한 자리 곱셈으로 줄일 수 있다.
또한, 카라츠바 알고리즘은 분할 정복 (Divide and Conquer)에 기반하고 있다.
카라츠바 알고리즘은 두 큰 수 $x$, $y$의 절반인 수들의 곱 3번과 시프트 연산을 이용하는 것이다.
왜냐하면, 곱셈의 경우 $O(n^2)$의 시간 복잡도를 가지고 있고 덧셈, 뺄셈의 경우 $O(n)$의 시간 복잡도를 가지고 있다. 카라츠바 알고리즘을 이용하여 곱셈의 횟수를 줄이고 덧셈, 뺄셈의 횟수를 늘리는 알고리즘이다.
$x$와 $y$를 $B$진법의 $n$자리 수라고 하자. $n$보다 작은 양수 $m$에 대해 $x$, $y$를 다음과 같이 쪼갤 수 있다.
- $x=x_1B^m+x_0 \space(x_0<B^m)$
- $y=y_1B^m+y_0 \space(y_0<B^m)$
위의 식을 다음과 같이 다시 정의한다.
- $z_2=x_1y_1$
- $z_1=x_1y_0+x_0y_1$
- $z_0=x_0y_0$
이제, x와 y의 곱은 다음과 같이 정리할 수 있다.
- $xy=(x_1B^m+x_0)(y_1B^m+y_0)=z_2B^{2m}+z_1B^m+z_0$
한편, $z_1$에 대해 $z_2$와 $z_0$를 이용하여 다음과 같이 정리할 수 있다.
- $\begin{matrix} z_1 &=& (x_1y_1+x_1y_0+x_0y_1+x_0y_0)-x_1y_1-x_0y_0=x_1y_0+x_0y_1 \ &=&(x_1+x_0)(y_1+y_0)-z_2-z_0 \end{matrix}$
카라츠바 알고리즘 구현
두 큰 수 $x$, $y$는 벡터를 이용할 것이다.
void nomalize(vector<int> &num) {
num.push_back(0);
int size=num.size();
for(int i=0;i<size-1;i++) { // 자릿수 올림 계산
if(num[i]<0) {
int carry=(abs(num[i])+9)/10;
num[i+1]-=carry;
num[i]+=carry*10;
}
else {
num[i+1]+=num[i]/10;
num[i]%=10;
}
}
while(num.size()>1 && num.back()==0) // 앞에 남은 0을 제거한다
num.pop_back();
}
// multiply
vector<int> multiply(vector<int> &a, vector<int> &b) {
vector<int> c(a.size()+b.size()+1,0);
for(int i=0;i<a.size();i++) {
for(int j=0;j<b.size();j++) {
c[i+j]+=a[i]*b[j];
}
}
normalize(c);
return c;
}
// a+=b*10^k
void add(vector<int> &a, vector<int> &b, int k) {
int ASize=a.size(); // 원래 a 길이
if(a.size()<b.size()) {
a.resize(b.size()+k);
}
a.push_back(0);
for(int i=ASize;i<a.size();i++) a[i]=0;
for(int i=0;i<b.size();i++) a[i+k]+=b[i];
normalize(a);
}
// a-=b (a>=b)
void sub(vector<int> &a, vector<int> &b) {
for(int i=0;i<b.size();i++) a[i]-=b[i];
normalize(a);
}
vector<int> karatsuba(vector<int> &a, vector<int> &b) {
// a.size()<b.size() -> swap(a,b)
if(a.size()<b.size()) return karatsuba(b,a);
// 기저사례1 : a나 b가 비어있는 경우
if(a.size()==0 || b.size()==0) return vector<int>();
// 기저사례2 : a가 비교적 짧은 경우, O(n^2)곱셈으로 변경한다(성능을 위함)
if(a.size()<=50) return multiply(a,b);
// a와 b를 a0, a1, b0, b1으로 분리
int half=a.size()/2;
vector<int> a0(a.begin(),a.begin()+half);
vector<int> a1(a.begin()+half,a.end());
vector<int> b0(b.begin(),b.begin()+min(b.size(),half));
vector<int> b1(b.begin()+min(b.size(),half),b.end());
// z2=a1*b1
vector<int> z2=karatsuba(a1,b1);
// z0=a0*b0
vector<int> z0=karatsuba(a0,b0);
// z1=(a0+a1)(b0+b1)-z2-z0
add(a0,a1,0);
add(b0,b1,0);
vector<int> z1=karatsuba(a0,b0);
sub(z1,z0);
sub(z1,z2);
// 병합 과정
vector<int> ret(z2.size()+half*2,0);
add(ret,z0,0);
add(ret,z1,half);
add(ret,z2,half*2);
return ret;
}
카라츠바 알고리즘의 성능
$a$, $b$의 자릿수 $n$이 $2^k$ 라고 할 때, 재귀 호출의 깊이는 $k$가 된다. 한 번 쪼갤 때 마다 수행해야 할 곱셈의 연산이 3배씩 증가하므로 마지막 단계에서 $3^k$번의 연산이 필요하다. 그러나 마지막 단계는 한 자리수 곱셈이므로 곱셈 한 번이면 충분하다.
따라서 곱셈 횟수는 $O(3^k)$의 시간 복잡도를 가지고 있는데 $n=2^k$이므로 $k=logn$이다.
그래서 $O(3^k)=O(3^{logn})=O(n^{log3})$의 시간 복잡도를 가지게 된다. $(a^{logb}=b^{loga})$
반면, 병합과정에 있어 덧셈과 뺄셈 과정만 하므로 O(n)의 시간 복잡도를 가지는데 재귀 호출의 깊이가 증가할 때마다 수의 길이는 절반이 되고 연산의 횟수는 3배가 되므로 깊이 k에서 연산 횟수는 $(\frac{3}{2})^kn$가 된다.
총 연산 수는 $n\sum_{i=0}^{log(n)}(\frac{3}{2})^i$인데 $n^{log(3)}$과 같이 따라가므로 최종 시간 복잡도는 $O(n^{log3})$이 된다.
Comments