1. 재귀
1 - 1) 재귀 함수란 자기 자신을 호출하는 함수다.
1 - 2) 재귀 함수는 '분할 정복' 방식을 통해 복잡한 문제를 해결한다.
2. 재귀의 규칙
재귀 함수를 잘못 구현한 경우 심각한 문제를 일으킨다. 프로그램이 어느 한 곳에 빠져서 종료되지 않기 때문이다.
무한 재귀 호출은 스택 오버플로(Stack Overflow)를 초래한다. 스택 오버플로란 프로그램의 콜 스택 최대 개수가
제한된 양의 주소 공간(메모리)을 초과할 때를 나타낸다.
2 - 1) 기저 조건
재귀에는 기저 조건(종료 조건)이 존재한다. 아니 존재해야 한다! 재귀의 메커니즘은 자기 자신을 호출하기 때문에
기저 조건에 도달하지 않으면 중단되지 않고 계속 자기 자신을 호출한다. 재귀로 인한 스택 오버플로는 올바른 기저
조건을 갖추지 못한 결과일 가능성이 매우 높다.
그 예로 n부터 0까지 세면서 숫자를 출력하는 다음 함수를 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
|
function countDownToZero(n) {
// 기저 조건, 0에서 중단된다.
if (n < 0) {
return;
} else {
console.log(n);
countDownToZero(n - 1); //1만큼 감소시킴.
}
}
countDownToZero(7);
|
cs |
위 함수의 기저 조건은 n이 0보다 작거나 같은 경우다. 음수가 입력으로 주어지면 이미 기저 조건에 도달한 것이기
때문에 어떤 숫자도 출력되지 않는다. 기저 조건 외에도 재귀 함수는 분할 정복 방식을 활용한다.
2 - 2) 분할 정복 방식
분할 정복 방식이란 어떤 문제를 작은 단위로 나눠서 해당 작은 단위의 문제들을 모두 해결함으로써 문제를 해결하는
것을 말한다. 위 예에서 7, 6, 5, 4, 3, 2, 1, 0을 출력하는 하나하나가 작은 단위인 것이다. 그렇게 작은 문제를 해결하면서
기저에 도달해야 올바른 재귀적 매커니즘이라 할 수 있다.
ex) 피보나치 수열의 n번째 숫자를 출력하는 함수를 재귀 함수로 구현해보자.
1
2
3
4
5
6
7
8
9
|
function getNthFibo(n) {
//기저 경우
if (n <= 1) {
return n;
} else {
return getNthFibo(n - 1) + getNthFibo(n - 2);
}
}
|
cs |
기저 경우 : 피보나치 수열의 기저 경우는 첫 번째 항목이 1일 때다.
분할 정복 : 피보나치 수열의 정의에 따라 n번째 수는 (n-1) 번째 + (n-2) 번째 피보나치 수의 합이다.
하지만 이러한 구현의 시간 복잡도는 O(2^n)이다.
피보나치 수열 : 꼬리 재귀
꼬리 재귀 함수는 재귀 호출이 함수에서 가장 마지막에 실행되는 방식의 재귀 함수다.
1
2
3
4
5
6
7
8
9
10
|
function getNthFiboBetter(n, lastlast, last) {
if (n === 0) {
return lastlast;
}
if (n === 1) {
return last;
}
return getNthFiboBetter(n - 1, last, lastlast + last);
}
|
cs |
시간 복잡도 : 함수는 n번 실행된다. 재귀 호출이 일어날 때마다 n - 1씩 감소하기 때문이다.
공간 복잡도 : 위의 함수에 사용된 스택 콜 때문에 공간 복잡도 역시 O(n)이다.
이처럼 재귀는 코드를 간결하고 아름답게 만든다!
3. 재귀의 빅오 분석 - 마스터 정리
마스터 정리는 다음과 같다. a >= 1이고 b >= 1인 T(n) = aT(n/b) + O(n^c)의 형태를 지닌 점화식이 있을 때
세 가지 경우가 존재한다.
a는 재귀 호출에 곱해지는 계수다. b는 '로그'항이다. b는 재귀 호출 시에 나누는 항이다. 마지막으로 c는
등식의 비 재귀 구성 요소에 대한 다항식의 항이다.
*log_{x}{y} : 밑이 x이고 진수가 y인 로그
3 - 1) 경우 1 : c < log _{b}{a} 이면 O(n^(log _{b}{a}))이다.
예를 들어 T(n) = 8T(n/2) + 1000n^2이라고 하면 a = 8, b = 2, c = 2이고 2 < log_{2}{8}이니까 T(n) = O(n^3)이다.
3 - 2) 경우 2 : c = log _{b}{a}이면 T(n) = O(n^c * log(n))이다.
예를 들어 T(n) = 2T(n/2) + 10n이라고 하면 a = 2, b = 2, c = 1이고 1 = log_{2}{2}이므로 T(n) = O(n^1 * log(n)) = O(nlogn)
3 - 3) 경우 3 : c > log _{b}{a}이면 T(n) = O(f(n))이다.
예를 들어 T(n) = 2T(n/2) + n^2이라고 해보자. a = 2, b = 2, c = 2이고 2 > log_{2}{2}이므로 T(n) = f(n) = O(n^2)
사실.. 블로킹하면서 본인도 무슨 소리인지 잘 모르겠다. 재귀의 알고리즘 빅오를 분석하는 것은 꽤나 어려운 것 같다.
4. 재귀 호출 스택 메모리
재귀 함수는 자기 자신을 호출하기 때문에 메모리를 차지한다. 따라서 재귀 함수는 공간 복잡도 또한 신경을 써줘야 한다.
1
2
3
4
5
6
|
function printNrecursive(n) {
console.log(n);
if (n > 1) {
printNrecursive(n - 1);
}
}
|
cs |
예를 들어 위와 같은 함수는 함수가 n번 호출되므로 공간 복잡도는 O(n)이다.
재귀 함수의 호출은 운영체제의 메모리 스택에 저장돼야 하는데, 이러한 재귀 호출로 인해 발생하는 추가적인 공간 복잡도
비용을 지닌다. 스택은 재귀의 기저 경우가 해결될 때까지 축적된다. 사실 이러한 이유로 인해 재귀 해결책보다 반복 루프를
활용한 해결책을 선호하기도 한다. 최악의 상황으로 기저 경우가 잘못되면 재귀함수는 스택 오버플로 오류로 인해 치명적인
오류를 일으킨다. 스택 오버플로는 메모리 스택에 허용된 수의 항목보다 재귀 호출이 많을 때 일어난다.
5. 요약
재귀는 복잡한 알고리즘을 구현하기 위한 강력한 도구다. 모든 재귀함수는 기저 경우와 분할 정복 방식으로 구성된다.
이러한 재귀 알고리즘의 빅오는 경험적으로 수행되거나 마스터 정리를 통해 수행된다. 마스터 정리를 사용하는 경우
점화식이 필요하고 해당 점화식의 a, b, c를 식별해야 한다. 마지막으로 재귀 함수는 콜 스택에서의 공간을 필요로 한다.
'Computer Science > 자료구조와 알고리즘' 카테고리의 다른 글
제 6장 - 자바스크립트 객체 (0) | 2020.10.27 |
---|---|
제 5장 - 자바스크립트 배열 (0) | 2020.10.26 |
제 4장 - 자바스크립트 문자열 (0) | 2020.10.20 |
제 3장 - 자바스크립트 숫자 (0) | 2020.10.15 |
제 2장 - 자바스크립트의 독특한 특징 (0) | 2020.10.13 |