나뭇잎 교과서

재귀적 알고리즘

점화 관계를 그대로 알고리즘으로 옮기면 자기 자신을 다시 부르는 재귀적 알고리즘이 됩니다. 재귀는 짧고 분명하지만, 같은 계산을 반복하면 매우 느려질 수 있고, 문제를 절반으로 줄이면 놀랄 만큼 빨라질 수 있습니다. 이 절에서는 호출 나무로 재귀의 비용을 세어 보고, 거듭제곱을 몇 번의 곱셈으로 줄일 수 있는지 도전합니다.

본문

재귀적 알고리즘

본문

피보나치 수 \(F(n)\)을 구하는 프로그램을 "\(n\)이 1이나 2이면 1을 돌려주고, 아니면 \(F(n-1) + F(n-2)\)를 돌려준다"로 짰다. 정의를 그대로 옮긴 깔끔한 프로그램인데, \(F(30)\)을 구하는 데 함수가 160만 번 넘게 불렸다. 반면 \(a^{100}\)을 구하는 프로그램은 자기 자신을 부르는 같은 방식을 쓰는데도 곱셈 여덟 번 만에 끝났다. 자기 자신을 부르는 알고리즘은 언제 느리고 언제 빠를까?

F(5) F(4)F(3) F(3)F(2)F(2)F(1) F(2)F(1) F(3)은 두 번, F(2)는 세 번 다시 계산된다.
그림 1. 같은 값을 여러 갈래에서 되풀이해 계산하므로 호출 수가 피보나치 수처럼 늘어난다.
먼저 알아야 할 것점화 관계(점화 관계와 일반항), 알고리즘(알고리즘과 그 표현), 지수법칙.

2.4.1자기 자신을 부르는 알고리즘

문제를 같은 꼴의 더 작은 문제로 바꾸어 풀도록 자기 자신을 다시 부르는 알고리즘을 재귀적 알고리즘이라 한다. 재귀적 알고리즘에는 더 이상 나누지 않고 바로 답을 내는 기저 단계와, 작은 문제의 답으로 큰 문제의 답을 만드는 재귀 단계가 있어야 한다. 기저 단계가 없거나 문제가 작아지지 않으면 끝없이 자기 자신을 불러 멈추지 않는다. 재귀적 알고리즘은 점화 관계를 그대로 옮긴 것이어서 쓰기 쉽고 옳은지 확인하기도 쉽다.

정리 2.4.1 재귀 호출의 횟수

피보나치 수를 정의대로 재귀 호출하면 \(F(n)\)을 구하는 호출 수 \(C(n)\)은 \(C(1) = C(2) = 1\), \(C(n) = C(n-1) + C(n-2) + 1\)을 만족해 \(C(n) = 2F(n) - 1\)이다. 거듭제곱을 \(a^{2k} = (a^k)^2\), \(a^{2k+1} = a\cdot(a^k)^2\)으로 재귀 계산하면 곱셈 횟수는 \(\log_2 n\)의 두 배 이하이다.

피보나치의 재귀는 한 번 부를 때마다 두 번 다시 부르고, 그 두 갈래가 같은 값을 따로 계산한다. 그래서 호출 수가 피보나치 수 자체처럼 지수적으로 늘어난다. 한 번 계산한 값을 표에 적어 두고 다시 필요할 때 꺼내 쓰면, 각 \(F(k)\)를 한 번씩만 계산해 호출이 \(n\)번 정도로 줄어든다. 반면 빠른 거듭제곱은 문제를 절반 크기의 문제 하나로 바꾸므로 단계 수가 로그만큼만 늘어난다. 재귀가 빠른지 느린지는 문제를 몇 개로, 얼마나 작게 나누는지와 같은 계산을 되풀이하는지에 달려 있다.1

2.4.2절반으로 줄이는 거듭제곱

\(a^{100}\)을 \(a\)를 99번 곱해 구하는 대신, \(a^{100} = (a^{50})^2\), \(a^{50} = (a^{25})^2\), \(a^{25} = a\cdot(a^{12})^2\)처럼 지수를 절반으로 줄여 가며 재귀적으로 구한다. 지수가 짝수이면 제곱 한 번, 홀수이면 제곱 한 번과 \(a\)를 곱하는 한 번이 필요하다. 이 과정은 지수를 2진법으로 쓴 것과 같아서, \(100 = 1100100_{(2)}\)의 자릿수가 제곱의 횟수를, 1의 개수가 추가 곱셈의 횟수를 정한다. 그래서 지수가 아주 커도 곱셈 횟수는 지수의 자릿수 정도로만 늘어난다.

알고리즘n = 30, 100늘어나는 빠르기 피보나치 재귀호출 1,664,079번지수적 값을 적어 두는 재귀호출 약 30번n에 비례 빠른 거듭제곱 a¹⁰⁰곱셈 8번log n에 비례
그림 2. 같은 계산을 되풀이하지 않고 문제를 절반씩 줄이면 계산량이 크게 준다.

2.4.3예제 풀이

예제 2.4.2

피보나치 재귀로 \(F(30) = 832040\)을 구할 때의 호출 수를 구하여라. 또 빠른 거듭제곱으로 \(a^{100}\)을 구할 때 필요한 곱셈 횟수를 단계별로 세어라.

호출 수는 \(C(n) = 2F(n) - 1\)이므로 \(C(30) = 2\times832040 - 1 = 1664079\)번이다. 값을 표에 적어 두면 \(F(3)\)부터 \(F(30)\)까지 한 번씩, 28번의 덧셈으로 끝난다.

빠른 거듭제곱은 \(a^{100} \leftarrow a^{50} \leftarrow a^{25} \leftarrow a^{12} \leftarrow a^6 \leftarrow a^3 \leftarrow a^1\)로 지수를 줄인다. 거꾸로 올라가며 세면 \(a^3 = a\cdot a^2\)에서 2번, \(a^6\), \(a^{12}\)에서 1번씩, \(a^{25} = a\cdot(a^{12})^2\)에서 2번, \(a^{50}\), \(a^{100}\)에서 1번씩이어서 모두 8번이다. 99번 곱하는 것보다 열 배 이상 빠르다. 즉 재귀적 알고리즘은 기저 단계와 재귀 단계로 문제를 작은 같은 꼴로 바꾸며, 같은 계산을 되풀이하면 지수적으로 느려지고 문제를 절반씩 줄이면 로그만큼만 늘어난다.

아래 탐구 카드에서 피보나치 재귀의 호출 나무를 그려 보고, 빠른 거듭제곱으로 \(a^{100}\)을 곱셈 몇 번 만에 구하는지 세어 보자.

  1. 계산한 값을 적어 두었다가 다시 쓰는 방법을 동적 계획법이라 한다. 1950년대 미국의 벨먼이 이름을 붙였으며, 오늘날 길 찾기, 맞춤법 검사, 유전자 서열 비교처럼 작은 문제의 답이 여러 번 쓰이는 계산에 널리 쓰인다.
마무리

재귀적 알고리즘은 바로 답을 내는 기저 단계와 같은 꼴의 작은 문제로 바꾸는 재귀 단계로 자기 자신을 부르며, 점화 관계를 그대로 옮겨 쓰기 쉽다. 피보나치처럼 두 갈래가 같은 계산을 되풀이하면 호출 수가 \(2F(n) - 1\)로 지수적으로 늘고, 빠른 거듭제곱처럼 문제를 절반으로 줄이면 계산량이 \(\log n\)에 비례한다. 처음 질문의 \(F(30)\)은 같은 값을 거듭 계산해 수백만 번 불렸고, \(a^{100}\)은 지수를 절반씩 줄여 곱셈 여덟 번 만에 구했다.

탐구 1

재귀로 피보나치 수를 구하면 왜 이렇게 느릴까?

재귀적 알고리즘 · 조작

  1. 질문"\(F(n) = F(n - 1) + F(n - 2)\), \(F(1) = F(2) = 1\)"을 그대로 컴퓨터 함수로 쓰면 아주 짧은 프로그램이 된다. 그런데 \(F(40)\)만 넣어도 한참 걸린다. 무엇이 문제일까?
  2. 복원수열의 귀납적 정의는 앞의 항으로 다음 항을 정한다.
  3. 설명자기 자신을 더 작은 입력으로 다시 부르는 알고리즘을 재귀적 알고리즘이라 한다. 재귀에는 ① 더 이상 부르지 않는 기저 경우(\(F(1), F(2)\))와 ② 문제를 줄이는 재귀 단계가 있어야 한다. 피보나치를 그대로 재귀로 쓰면 \(F(n - 2)\)를 두 번, \(F(n - 3)\)을 세 번…처럼 같은 계산을 거듭해, 호출 횟수 \(c_n = c_{n-1} + c_{n-2} + 1\)이 \(F(n)\)처럼 기하급수로 늘어난다. 한 번 구한 값을 기억해 두면(메모이제이션) 각 값을 한 번씩만 계산한다.
  4. 연결하노이의 탑, 다음 카드의 빠른 거듭제곱, 병합 정렬처럼 재귀가 잘 맞는 문제도 많다. 호출 횟수를 점화식으로 세우는 것이 알고리즘의 효율을 따지는 기본 방법이다.
  5. 적용\(n\)을 늘리며 호출 나무가 얼마나 빨리 커지는지 보자. 같은 색 마디가 몇 번 나오는가? "기억하기"를 켜면 나무가 어떻게 바뀌는가? 아래에서 \(n = 30, 40\)일 때 호출 횟수를 비교해 보자.
  6. 판단기저 경우가 없거나 입력이 줄지 않으면 재귀는 끝나지 않는다. 또 짧고 아름다운 코드가 빠른 코드는 아니다. 정의를 그대로 옮기기 전에 같은 계산이 반복되는지 따져 보자.

확인조건이 달라지면

기억하기 없이 \(F(5)\)를 재귀로 구할 때 \(F(2)\)는 몇 번 불릴까?

탐구 2

a¹⁰⁰을 곱셈 몇 번 만에 구할 수 있을까?

분할 정복 재귀 · 도전

지금 가진 것 a¹

  1. 질문\(a^{100}\)을 \(a \times a \times \cdots\)로 구하면 곱셈이 99번 필요하다. 그런데 \(a^{100} = (a^{50})^2\)이고 \(a^{50} = (a^{25})^2\)이다. 이렇게 하면 곱셈을 얼마나 줄일 수 있을까?
  2. 복원\(a^{m}\cdot a^{n} = a^{m+n}\), \((a^m)^2 = a^{2m}\)이다.
  3. 설명재귀적 알고리즘 \(\mathrm{pow}(a, n)\)은 \(n = 0\)이면 1, \(n\)이 짝수이면 \(\mathrm{pow}(a, n/2)^2\), 홀수이면 \(a \cdot \mathrm{pow}(a, n - 1)\). 문제를 절반 크기로 줄이므로(분할 정복) 곱셈 횟수는 \(n\)의 이진법 자릿수와 1의 개수 정도, 약 \(2\log_2 n\)번 이하이다. \(n = 100\)이면 8번, \(n = 1000\)이면 14번이면 된다.
  4. 연결인터넷 보안의 RSA 암호는 수백 자리 수의 거듭제곱을 나머지 계산으로 하는데, 이 빠른 거듭제곱 없이는 불가능하다. 고급 대수에서 본 행렬의 거듭제곱 \(A^n\)에도 그대로 쓸 수 있다.
  5. 적용"제곱하기"와 "a 곱하기"만으로 목표 지수에 정확히 도착해 보자. 곱셈 횟수가 재귀 알고리즘보다 적거나 같으면 성공이다. 목표를 이진법으로 쓰면 어떤 규칙이 보이는가?
  6. 판단재귀 알고리즘이 늘 가장 적은 곱셈을 쓰는 것은 아니다. 예를 들어 \(a^{15}\)는 재귀로 6번이지만, \(a^3\)을 먼저 만든 뒤 \((a^3)^5\)를 계산하는 등 다른 길로 5번에 할 수 있다. "충분히 빠른 방법"과 "가장 빠른 방법"은 다른 문제이다.

확인조건이 달라지면

재귀 알고리즘으로 \(a^{16}\)을 구할 때 곱셈 횟수는?

AI 출제 문항연습문제 풀어보기10문항
Esc

↑↓ 고르기Enter 열기