티스토리 뷰

모두 메리 크리스마스!

 

 

 

▣ 정수론 시리즈 ─ 순서대로 읽으시는 걸 추천합니다.

1. 뫼비우스 함수와 디리클레 합성곱

2. Double Counting과 Harmonic Lemma

3. 곱셈적 함수 테이블 만들기 (현재 글)

4. Mertens Trick (xudyh's sieve)

 

  「뫼비우스 함수와 디리클레 합성곱」 게시글에서 여러 곱셈적 함수들의 예시를 소개하였다. 본 글에서는, 곱셈적 함수들의 테이블을 만드는 코드들을 작성해 볼 것이다. 여기서 "함수의 테이블을 만든다"의 의미는 어떤 양의 정수 $N$과 함수 $f(n)$에 대해 $f(1)$, $f(2)$, $\cdots$, $f(N)$의 값을 모두 구한다는 의미이다.

 

양의 약수의 개수 $\tau(n)$

  naive하게 $N$개의 값을 모두 구하면 $\mathcal O(N^{\frac{3}{2}})$의 시간복잡도가 소요된다. 그러나 더블 카운팅을 사용하여, 약수 관점이 아니라 배수 관점으로 본다면 시간복잡도를 줄일 수 있다. 일일이 약수 개수를 세는 것이 아니라 $1$의 배수 함숫값들에 $1$씩 더하고, $2$의 배수 함숫값들에 $1$씩 더하고, , $N$의 배수 함숫값들에 $1$씩 더해도 $1$부터 $N$까지의 모든 정수의 약수의 개수가 나올 것이다. 이를 코드로 나타내면 다음과 같다.

#include <stdio.h>
#define NMAX 1000003

int tau[NMAX];

int main()
{
	int N, i, j;
	scanf("%d", &N);

	for (i = 1; i <= N; ++i)
		for (j = 1; j*i <= N; ++j) tau[j*i]++;

	for (i = 1; i <= N; ++i) printf("%d ", tau[i]);
	return 0;
}

  에라토스테네스의 체 구현과 비슷한 느낌의 코드다. 위 코드의 시간복잡도를 분석해 보면, $1$ 이상 $N$ 이하의 $i$에 대해 안쪽 for문이 $\lfloor \frac{N}{i} \rfloor$번씩 동작하므로 총 반복문 동작 횟수는

$$\sum _{i=1}^N {\left\lfloor \frac{N}{i} \right\rfloor} \le \sum _{i=1}^N \frac{N}{i} \cong N \ln N$$

가 되어, 코드의 시간복잡도는 $\mathcal O(N \log N)$이 된다. naive한 방법에 비해 상당히 향상되었음을 알 수 있다.

 

양의 약수의 합 $\sigma(n)$

  $\tau(n)$과 유사하게 접근한다. naive하게 일일이 함숫값을 구하면 역시 $\mathcal O(N^{\frac{3}{2}})$이지만, 배수 관점으로 접근해서 풀어보면 $1$의 배수 함숫값들에 $1$씩 더하고, $2$의 배수 함숫값들에 $2$씩 더하고, , $N$의 배수 함숫값들에 $N$씩 더하는 것으로 볼 수 있다. 이를 코드로 나타내면 다음과 같다.

#include <stdio.h>
#define NMAX 1000003

int sigma[NMAX];

int main()
{
	int N, i, j;
	scanf("%d", &N);

	for (i = 1; i <= N; ++i)
		for (j = 1; j*i <= N; ++j) sigma[j*i] += i;

	for (i = 1; i <= N; ++i) printf("%d ", sigma[i]);
	return 0;
}

  이 코드의 시간복잡도 역시 $\mathcal O(N \log N)$이다.

 

오일러 토션트 함수 $\phi(n)$

  $\phi(n)$의 정의에 입각해서 구한다면, 각 $n$마다 $1$부터 $n$까지의 수를 보며 서로소인지 판별해야 하므로 함숫값 하나 구하는 데에 $\mathcal O(n)$이 걸리게 되어 총 시간복잡도는 $\mathcal O(N^2)$이 된다. 하지만 우리는 $\phi(n)$의 공식을 알고 있으며, 이를 이용해 구한다면 $\mathcal O(N^{\frac{3}{2}})$으로 줄어든다. 그런데 위 함수들처럼 더욱 빨리(구체적으로 $\mathcal O(N \log N)$ 정도의 시간복잡도로) 구할 수는 없을까?

 

  이 글의 〈뫼비우스 반전〉 단락에서 보듯이, $$\sum_{d|n}\phi(d) = n$$가 성립한다. 이를 살짝 변형하면 $$\phi(n) = n - \sum_{d|n,\, d \ne n}\phi(d)$$가 되는데, 몇 개의 작은 $n$에 대해 이 식을 풀어 써 보면 다음과 같다.

$$\begin{aligned}\phi(1) &= 1 \\ \phi(2) &= 2 - \phi(1)  \\ \phi(3) &= 3 - \phi(1) \\ \phi(4) &= 4 - \phi(1) - \phi(2) \\ \phi(5) &= 5 - \phi(1) \\ \phi(6) &= 6 - \phi(1) - \phi(2) - \phi(3) \\ \phi(7) &= 7 - \phi(1) \\ \phi(8) &= 8 - \phi(1) - \phi(2) \,\quad\qquad - \phi(4) \\ \phi(9) &= 9 - \phi(1) \quad\qquad - \phi(3) \end{aligned} \\ \vdots$$

 

  여기서 배수 관점으로 전환한다면, $\phi(n)$의 값을 구할 때 우선 $n$을 더하고, $1$ 이상 $N$ 이하의 모든 정수 $i$에 대해 자기 자신을 제외한 모든 배수들(즉 $2i$, $3i$, 등)의 함숫값에서 $\phi(i)$가 빠지는 걸 볼 수 있다. 다시 말해 모든 $j \ge 2$에 대해 $\phi(ji)$에서 $\phi(i)$를 빼는 과정을, 모든 $1 \le i \le N$에 대해서 반복하면 $1 \le n \le N$인 모든 $n$에 대해 $\phi(n)$의 값을 구할 수 있다! 이를 코드로 작성하면 다음과 같다.

#include <stdio.h>
#define NMAX 1000003

int phi[NMAX];

int main()
{
	int N, i, j;
	scanf("%d", &N);

	for (i = 1; i <= N; ++i) {
		phi[i] += i;
		for (j = 2; j*i <= N; ++j) phi[j*i] -= phi[i];
	}

	for (i = 1; i <= N; ++i) printf("%d ", phi[i]);
	return 0;
}

  그리고 이 코드 역시, $\mathcal O(N \log N)$의 시간복잡도를 가진다. 모든 $\phi(n)$에 대해 $n$을 더해주는 것을 별개의 반복문으로 처리할 수도 있지만, 좀 더 간결하게 처리한 것에 주목하라.

 

뫼비우스 함수 $\mu(n)$

  뫼비우스 함수도 정의에 따라 구하려면 $n$의 서로 다른 소인수의 개수가 필요하기 때문에, 필연적으로 $\mathcal O(N^{\frac{3}{2}})$의 시간복잡도를 가진다. 그런데 전에 뫼비우스 함수를 소개할 때, 뫼비우스 함수의 성질 중

$$\sum_{d|n}\mu(d) = \begin{cases} 1 & (n = 1) \\ 0 & (n > 1) \end{cases}$$

가 성립한다고 했던 것이 기억나는가? 이 식을 정리하면

$$\mu(n) = \begin{cases} \qquad\quad 1 & (n = 1) \\ - \sum\limits_{d|n,\, d \ne n} \mu(d) & (n > 1) \end{cases}$$

와 같고, 위의 $\phi(n)$ 함수 때와 비슷하게 생각해 보면, $\mu(1) = 1$이고 나머지 값에 대해서는 $j \ge 2$인 모든 $j$에 대해 $\mu(ji)$에서 $\mu(i)$를 빼 주는 걸 모든 $i$에 대해 반복하면 된다. 아래는 이를 바탕으로 작성한 코드다.

#include <stdio.h>
#define NMAX 1000003

int mu[NMAX] = {0, 1}; // 편의상 mu[0] = 0이라 둔다.

int main()
{
	int N, i, j;
	scanf("%d", &N);

	for (i = 1; i <= N; ++i)
		for (j = 2; j*i <= N; ++j) mu[j*i] -= mu[i];

	for (i = 1; i <= N; ++i) printf("%d ", mu[i]);
	return 0;
}

  이 코드도 위의 코드들과 마찬가지로 $\mathcal O(N \log N)$에 동작한다.

 

합성곱 $f \ast g$

  조금 일반화해서, 디리클레 합성곱 $(f \ast g)(n)$의 테이블도 $\mathcal O(N \log N)$에 채울 수 있을까? 물론이다!

 

$$(f \ast g)(n) = \sum_{d|n}f(d)g{\left(\frac{n}{d}\right)} = \sum_{ij=n}f(i)g(j)$$

이므로 $1 \le i \le N$인 모든 $i$에 대해, $(f \ast g)(ji)$에 $f(i)g(j)$를 모든 $j \ge 1$에 대해 더해주는 걸 반복하면 된다. 이를 코드로 나타내면 다음과 같고, 시간복잡도는 $\mathcal O(N \log N)$이다.

#include <stdio.h>
#define NMAX 1000003

int f[NMAX], g[NMAX], fg[NMAX];

int main()
{
	int N, i, j;

	scanf("%d", &N);
	for (i = 1; i <= N; ++i) scanf("%d", &f[i]);
	for (i = 1; i <= N; ++i) scanf("%d", &g[i]);

	for (i = 1; i <= N; ++i)
		for (j = 1; j*i <= N; ++j) fg[j*i] += f[i] * g[j];

	for (i = 1; i <= N; ++i) printf("%d ", fg[i]);
	return 0;
}

 

코드 작성 시 참고할 점

  • $N$이 32bit 정수형의 범위를 넘어간다면, 각 코드에서 j*i를 계산할 때 오버플로우를 조심하여야 한다. 오버플로우를 방지하는 방법에 대해서는 내 블로그의 이 글을 참고하라.
  • 이렇게 함숫값들을 필요할 때마다 구하는 것이 아니라, 나올 수 있는 모든 값을 미리 전처리해 놓는 것이 더 효율적인 때가 종종 있다. 양쪽의 시간복잡도를 잘 계산해 보고 더 나은 방법을 선택해서 풀자.
  • 이 글에서는 $\mathcal O(N \log N)$의 시간복잡도를 가지는 방법에 대해서만 소개하였으나, $\mathcal O(N)$만에 테이블을 만들 수 있는 Linear Sieve라는 게 있다. 이에 대해 궁금한 분들은 해당 블로그에 친절히 잘 설명되어 있으므로 참고하는 걸 추천한다. 개인적으로는, 시간복잡도는 개선되었으나 실제 퍼포먼스에서의 유의미한 차이는 없는 것 같아 사용하지는 않고 있다. 이 부분은 개인의 기호에 따라 고르면 될 것 같다.
  • 자연스럽게 곱셈적 함수들의 부분합 테이블 또한 $\mathcal O(N \log N)$만에 채울 수 있게 되었다. "아니, 이전 글에서 $S_{\tau}(N)$과 $S_{\sigma}(N)$을 $\mathcal O(\sqrt N)$에 구하는 방법을 설명해서 의미가 없는 것 아닌가요?"라고 할 수 있지만, 그건 하나의 부분합만을 구하는 것이었고 이건 $N$개의 부분합을 구하는 것이라서 여전히 이 방법이 유효하다. 게다가 $S_{\phi}(N)$과 $S_{\mu}(N)$에 대해서는 아직 나온 바가 없기 때문에, $\phi(n)$과 $\mu(n)$ 한정으로는 부분합 하나를 구할 때도 유용하다!
  • 그렇다면 여기서 드는 한 가지 의문. $S_{\phi}(N)$과 $S_{\mu}(N)$을 $\mathcal O(N \log N)$보다 더 빨리 구할 수는 없을까?

 

 


 

 

  지금까지 여러 게시글을 넘고 넘은 끝에, 목표했던 정상이 코앞까지 다가왔다. 다음 글이 바로 내가 정수론 시리즈 게시글을 시작한 궁극적인 이유이자 목적이며, 그 글이 위 의문에 대한 답을 제공하게 될 것이다. 부디 다음 글에서도 만날 수 있기를 기대한다!

 

─ To Be Continued

'정보과학 > Instruction' 카테고리의 다른 글

C/C++에서의 몫 연산자 사용 팁  (0) 2020.10.08
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday