오늘은 javascript의 재귀함수에 대해 알아보자!
재귀란 무엇일까?
위 정의를 참고하여 재귀를 코드로 표현하면 다음과 같이 작성할 수 있을 것이다.
function recursion () {
console.log("This is")
console.log("recursion!")
recursion()
}
예를 하나 들어보자...
이 함수를 호출하면 어떻게 될까?
recursion
함수를 호출했더니, 자기 자신을 끝없이 호출하면서 같은 코드가 계속해서 실행되는 것을 볼 수 있다.recursion
함수처럼 자기 자신을 호출하는 함수를 재귀 함수라고 한다.그럼 어떻게하면 재귀를 활용해서 문제를 해결할 수 있을까?
문제: 자연수로 이루어진 리스트(배열)를 입력받고, 리스트의 합을 리턴하는 함수 `arrSum` 을 작성하세요.
물론 재귀 없이 반복문으로 해결하는 방법도 있다.
하지만 재귀를 이해해보기 위해 우선 이론적으로 재귀로 문제를 해결하는 단계는 다음과 같다.
1. 문제를 좀 더 작게 쪼갭니다.
2. 1번과 같은 방식으로, 문제가 더는 작아지지 않을 때까지, 가장 작은 단위로 문제를 쪼갭니다.
3. 가장 작은 단위의 문제를 풂으로써 전체 문제를 해결합니다.
이 단계를 적용해서 arrSum
함수를 작성해보자.
일단 배열 [1, 2, 3, 4, 5]
의 합을 구하는 과정을 재귀로 풀어보자.
어떻게하면 arrSum
함수로 [1, 2, 3, 4, 5]
의 합을 구하는 과정을 더 작게 쪼갤 수 있을까?
배열의 합을 구할 때 [1, 2, 3, 4, 5]
의 합을 구하는 것보다 [2, 3, 4, 5]
의 합을 구하는 것이 더 작은 문제이고, 여기서 또 [2, 3, 4, 5]
의 합을 구하는 것보다 [3, 4, 5]
의 합을 구하는 것이 더 작은 문제일 것이다.
아래의 코드를 보자!
arrSum([1, 2, 3, 4, 5]) === 1 + arrSum([2, 3, 4, 5])
arrSum([2, 3, 4, 5]) === 2 + arrSum([3, 4, 5])
...
위에서 문제를 쪼갠 방식을 반복해서 문제를 계속해서 쪼개면 더이상 쪼갤 수 없는 상태에 도달하게 된다.
...
arrSum([3, 4, 5]) === 3 + arrSum([4, 5])
arrSum([4, 5]) === 4 + arrSum([5])
arrSum([5]) === 5 + arrSum([])
마지막에는 arrSum
이 빈 배열을 받게되면서 문제를 더이상 쪼갤 수 없게 되었음으로 문제를 가장 작은 단위까지 쪼갰다고 할 수 있게 되었다.
문제가 더 쪼개지지 않게되면, 가장 작은 단위의 문제를 해결한다.
문제를 쪼갤 때 같은 방식으로 쪼갰기 때문에, 가장 작은 단위의 문제를 해결한 방식으로 문제 전체를 해결할 수 있게 되는 것이다.
2번에서 도달한 가장 작은 문제는 arrSum([])
이었다.
빈 배열의 합은 0이므로, 0을 리턴해주면 되는 것!
이렇게 가장 작은 문제를 해결하는 순간, 아래 코드처럼 쪼개졌던 문제가 거꾸로 거슬러 올라가면서 합쳐지게 된다.
arrSum([]) === 0; // <-- 문제가 더는 작아지지 않는 순간
// 가장 작은 경우의 해결책을 적용합니다.
arrSum([5]) === 5 + arrSum([]) === 5 + 0 === 5;
arrSum([4, 5]) === 4 + arrSum([5]) === 4 + 5 === 9;
arrSum([3, 4, 5]) === 3 + arrSum([4, 5]) === 3 + 9 === 12;
arrSum([2, 3, 4, 5]) === 2 + arrSum([3, 4, 5]) === 2 + 12 === 14;
arrSum([1, 2, 3, 4, 5]) === 1 + arrSum([2, 3, 4, 5]) === 1 + 14 === 15;
위의 코드처럼
arrSum
함수의 리턴값이 나오면서 연쇄적으로 문제가 해결되고, 최종적으로는 문제 전체가 해결되는 것을 볼 수 있다.
위 단계를 반영해서 arrSum
함수를 완성해보면 다음과 같다
function arrSum (arr) {
// 빈 배열을 받았을 때 0을 리턴하는 조건문
// --> 가장 작은 문제를 해결하는 코드 & 재귀를 멈추는 코드
if (arr.length === 0) {
return 0
}
// 배열의 첫 요소 + 나머지 요소가 담긴 배열을 받는 arrSum 함수
// --> 재귀(자기 자신을 호출)를 통해 문제를 작게 쪼개나가는 코드
return arr.shift() + arrSum(arr)
}
흠... 그렇다면 재귀는 언제 사용하는게 좋을까?
재귀는 다음과 같은 상황에서 매우 적합하다...
- 주어진 문제를 비슷한 구조의 더 작은 문제로 나눌 수 있는 경우
- 중첩된 반복문이 많거나 반복문의 중첩 횟수(number of loops)를 예측하기 어려운 경우
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
for (let k = 0; k < n; k++) {
for (let l = 0; l < n; l++) {
for (let m = 0; m < n; m++) {
for (let n = 0; n < n; n++) {
for (let o = 0; o < n; o++) {
for (let p = 0; p < n; p++) {
// do something
someFunc(i, j, k, l, m, n, o, p);
}
}
}
}
}
}
}
}
위의 코드를 보면 반복문 안의 반복문 사용으로 인해 코드가 더러워진다...
모든 재귀 함수는 반복문(while
문 또는 for
문)으로 표현할 수 있다.
그러나 재귀를 적용할 수 있는 대부분의 경우에는, 재귀를 적용한 코드가 더욱 간결하고 이해하기 쉬워진다.
이 밖에도, 재귀는 알고리즘 문제의 많은 부분을 차지하기 때문에 재귀를 사용하는 것이 좋다!
그럼 이제 마지막으로 재귀의 활용에 대해 알아보자!
1. 재귀 함수의 입력값과 출력값 정의하기
함수 arrSum
의 경우 number
타입을 요소로 갖는 배열을 입력으로 받고, number
타입을 리턴함. 이를 좀 더 간단하게 표기하면 다음과 같다.
arrSum: [number] => number
← 입출력값 정의2. 문제를 쪼개고 경우의 수를 나누기
다음으로는 주어진 문제를 어떻게 쪼갤 것인지 고민한다.
일반적으로, 입력값을 이 기준으로 정한다.
함수 arrSum
의 경우 입력값인 배열의 크기에 따라, 더 작은 문제로 나눌 수 있다. 그리고 arrSum([1, 2, 3, 4])
를 구하는 방법과 arrSum([2, 3, 4])
을 구하는 방법은 동일하므로, 이 구분은 적절하다고 판단할 수 있다.
이제 문제에서 주어진 입력값에 따라, 경우의 수를 나누는데,일반적으로 문제를 더 이상 쪼갤 수 없는 경우와 그렇지 않은 경우로 나눈다.
함수 arrSum
은 입력값이 빈 배열인 경우와 그렇지 않은 경우로 나눌 수 있다. 각각의 경우는 다른 방식으로 처리해야 한다.
- arrSum: [number] => number
- arrSum([ ])
← 입력값이 빈 배열인 경우
- arrSum([요소1, 요소2, ... , 요소n])
← 그렇지 않은 경우
3. 단순한 문제 해결하기
문제를 여러 경우로 구분한 다음에는, 가장 해결하기 쉬운 문제부터 해결한다.
이를 재귀의 기초(base case)라고 부른다.
재귀의 기초는 나중에 재귀 함수를 구현할 때, 재귀의 탈출 조건(재귀 호출이 멈추는 조건)을 구성한다
arrSum
을 더이상 쪼갤 수 없는 경우는 입력값이 빈 배열일 경우이고, 이때 arrSum([])
의 리턴값은 0.arrSum: [number] => number
arrSum([ ]) === 0
← 입력값이 빈 배열인 경우 : 해결!arrSum([요소1, 요소2, ... , 요소n])
4. 복잡한 문제 해결하기
남아있는 복잡한 경우를 해결한다.
길이가 1 이상인 배열이 함수 arrSum
에 입력된 경우, 입력된 배열을 배열의 첫 요소와 나머지 요소를 입력값으로 갖는 문제로 쪼개고, 둘을 더한다.
arrSum: [number] => number
arrSum([ ]) === 0
arrSum([요소1, 요소2, ... , 요소n]) === 요소1 + arrSum([요소2, ..., 요소n])
← 그렇지 않은 경우 : 해결!arrSum
을 재귀적으로 구현할 수 있다.5. 코드 구현하기
function arrSum(arr) {
// base case : 문제를 더 이상 쪼갤 수 없는 경우 (재귀의 기초)
if (arr의 길이가 0인 경우) {
return 0;
}
// recursive case : 그렇지 않은 경우
return 요소1 + arrSum([요소2, ... , 요소n]);
}
아래는 일반적인 재귀 함수의 템플릿 코드이다!
function recursive(input1, input2, ...) {
// base case : 문제를 더 이상 쪼갤 수 없는 경우
if (문제를 더 이상 쪼갤 수 없을 경우) {
return 단순한 문제의 해답;
}
// recursive case : 그렇지 않은 경우
return 더 작은 문제로 새롭게 정의된 문제
}