재귀에 대한 전반적인 개념
- 재귀함수
→ 어떤 문제를 해결할 때, 구조는 동일하지만 더 작은 경우를 해결함으로써 그 문제를 해결하는 방법을 재귀(recursion)라고 합니다
→ 재귀함수 활용
문제. 자연수의 리스트를 입력으로 받아 리스트의 합을 리턴하는 함수 `arrSum을` 작성하세요.
[첫번째. 반복문을 이용하여 구현]
- (자연수) 리스트의 합을 구하는 알고리즘
코드
function arrSum(arr) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
- }
[두번째. 재귀함수 이용]
자연수의 리스트 [10, 3, 6, 2]의 합을 구한다고 가정합시다.
- [10, 3, 6, 2]의 합을 구하는 건 신경쓰지 않고, 대신에 [3, 6, 2]의 합을 구하는 방법을 생각해 봅니다.
- [3, 6, 2]의 합을 구하는 것도 조금 버겁게 느껴져서, 대신에 [6, 2]의 합을 구하는 방법을 생각해 봅니다.
- 똑같은 이유로 [6, 2] 대신 [2]의 합을 구하는 방법을 생각해 봅니다. [2]의 합에 6을 더해 [6, 2]의 합을 구할 수 있습니다.
- [2]의 합을 구하는 건 매우 간단합니다. 2입니다.
이 과정을 아래의 간단한 코드로 표현할 수 있습니다.
arrSum([10, 3, 6, 2]) = 10 + arrSum([3, 6, 2]);
arrSum([3, 6, 2]) = 3 + arrSum([6, 2]);
arrSum([6, 2]) = 6 + arrSum([2]);
arrSum([2]) = 2 + arrSum([]);
arrSum([]) = 0;
arrSum을 아래와 같이 보다 엄밀하게(formally) 정의할 수 있습니다.
arrSum(arr)
1. arr이 빈 배열인 경우 = 0
2. 그 외의 경우 = arr[0] + arrSum(arr2)
(arr2는 arr의 첫 요소를 제외한 나머지 배열)
----------------------------------------------------------------------------------------------------------------
쪼개서 생각하는 방법
이처럼 어떤 문제를 해결할 때, 구조는 동일하지만 더 작은 경우를 해결함으로써 그 문제를 해결하는 방법을 재귀(recursion)라고 합니다
재귀는 언제 사용하는 것이 가장 좋을까
→ 1. 주어진 문제가 (구조는 비슷하고) 더 작은 문제로 나뉘어 질 수 있는 경우
2. 중첩된 루프가 많거나 중첩의 정도(number of loops)를 미리 알 수 없는 경우
→ 사실 모든 재귀 함수는 재귀 호출 없이 while / for loop으로 표현이 가능
→ 재귀를 사용 가능한 경우, 재귀를 사용한 코드가 대부분의 경우 더욱 간결하고, 일부의 경우에는 이해하기도 쉽습니다.
→ ‘하노이의 탑과 조합(combination) 문제’ (재귀함수 적용 문제)
Guide : 재귀적으로 사고하기
1. 재귀 함수의 입력값과 출력값 정의하기
→ 재귀 함수를 통해 어떤 문제를 풀고자 하는 지, 즉 우리의 목표를 정리하는 데 도움이 됩니다. → 문제를 가장 추상적으로 또는 가장 단순하게 정리하는 것입니다.
[예] arrSum의 경우 number 타입을 요소로 갖는 배열을 입력으로 받아서 number를 리턴합니다. 이를 좀더 간편하게 아래와 같이 표기한다고 가정합시다.
- arrSum: [number] => number
2. 문제를 쪼개고 경우의 수를 나누기
→ 주어진 문제를 어떻게 쪼갤 것인지 고민합니다.
→ 어떤 기준을 정하고 그 기준에 따라 문제를 더 큰 경우와 작은 경우로 구분할 수 있는 지 검토해봅니다.
→ 일반적으로 입력값이 이 기준을 정하는 대상이 됩니다.
→ 이 때 중요한 관점은 순서와 크기입니다.
→ 주어진 입력값 또는 문제 상황을 크기를 기준으로 구분할 수 있거나, 순서를 명확하게 정할 수 있는지를 살펴보는 것이 도움이 됩니다.
[예] arrSum의 경우 입력값인 배열을 크기에 따라 구분할 수 있습니다. 그리고 arrSum([1, 2, 3, 4])를 구하는 방법과 arrSum([2, 3, 4])을 구하는 방법은 동일할 것이므로 이 구분은 적절하다고 판단할 수 있습니다.
→ 이제 문제의 입력값에 따라 경우의 수를 나눕니다.
→ 일반적으로 문제를 더 이상 쪼갤 수 없는 경우와 그렇지 않은 경우로 나눕니다.
- arrSum은 입력값이 빈 배열인 경우와 그렇지 않은 경우로 나눌 수 있습니다. 각각의 경우는 다르게 처리되어야 합니다.
- arrSum: [number] => number
arrSum([ ])
arrSum([e1, e2, ... , en])
3. 단순한 문제 해결하기
→ 문제를 여러 경우로 구분한 다음에는 쉬운 문제부터 해결합니다.
→ 이를 재귀의 기초(base case)이라고 부릅니다.
→ 재귀의 기초는 나중에 재귀 함수 구현 시 재귀의 탈출 조건(재귀 호출이 멈추는 조건)을 구성하게 됩니다.
- arrSum를 더 이상 쪼갤 수 없는 경우는 입력값이 빈 배열일 경우이고, 이 때 arrSum은 0입니다.
- arrSum: [number] => number
arrSum([ ]) = 0
arrSum([e1, e2, ... , en])
4. 복잡한 문제 해결하기
남아있는 복잡한 경우를 해결합니다.
- arrSum이 길이가 1 이상인 배열을 입력으로 받을 경우, 맨 앞의 요소를 따로 구하고(배열의 맨 앞의 요소이기 때문에 head라고 이름 붙이겠습니다.), 나머지 요소를 새로운 입력값으로 갖는 문제를 해결하여 얻은 결과를 head에 더합니다.
- arrSum: [number] => number
arrSum([ ]) = 0
arrSum([e1, e2, ... , en]) = e1 + arrSum([e2, ..., en]) - 배열이 있을 때 head와 나머지 부분(tail)을 구분하는 방법만 안다면, arrSum을 해결할 수 있습니다.
5. 코드 구현하기
function arrSum(arr) {
// 재귀의 기초 (base case)
// 문제를 더 이상 쪼갤 수 없을 경우
if (arr의 길이가 0인 경우) {
return 0;
}
// recursive Case
// 그렇지 않은 경우
// head: 배열의 첫 요소
// tail: 배열의 첫 요소만 제거된 배열
return head + arrSum(tail);
}
아래는 일반적인 재귀 함수의 템플릿입니다. 재귀 함수 연습에 활용하시기 바랍니다.
function recursive(input1, input2, ...) {
// 재귀의 기초 (base case)
if (문제를 더 이상 쪼갤 수 없을 경우) {
return 단순한 문제의 해답;
}
// recursive Case
// 그렇지 않은 경우
return 더 작은 문제로 새롭게 정의된 문제
// 예1. someValue + recursive(input1Changed, input2Changed, ...)
// 예2. someValue * recursive(input1Changed, input2Changed, ...)
}
[예를 들어 팩토리얼을 재귀함수로 구현할 경우, 팩토리얼 : n! ->n*(n-1)*...*1]
[재귀함수의 예시]