함수가 자기 자신을 호출하는 것을 재귀 호출이라 한다.
분할 정복(Divide & Conquer), 점화식 등을 구현하는 데에 많이 사용된다.
재귀 호출을 통해 분할한 후 문제를 해결하면, 모든 원소 O(n)에 대하여 문제를 해결해야되었던 것이, 분할 하는 횟수(logN) 만큼의 처리횟수를 가져갈 수 있으므로 시간복잡도를 줄일 수 있다.
대표적인 분할 정복 알고리즘은 merge sort 인데, 분할 한 후, 분할 된 대상
재귀 구현은 항상 반복(Iteration) 구현으로 변환될 수 있다.
재귀식(Recursion relation)이라고도 부르며, 수열의 항 사이의 관계를 나타낸다.
일부 점화식은 일반식으로 풀이할 수 있다. 수열을 n에 대한 식으로 표현하는 것을 '풀이한다'고 한다.
점화식의 예
재귀 호출을 통한 재귀식을 구현하는데에는 두 가지 조건이 있다. 이것은 반복문에서도 적용되는 문제이므로 특별히 재귀에만 해당되는 문제가 아니라, 반복을 구현할 경우에 해당되는 조건이다.
def fibonacci(n):
if n < 2: # 탈출 조건
return n
return fibonacci(n-1) + fibonacci(n-2) # 재귀 호출(점화식 구현)
def quick_sort(x):
if len(x) <= 1:
return x # 잘린 배열의 길이가 1 이하일 경우 정렬이 필요없으므로 return
pivot = len(x) // 2 # 중앙값을 사용, 어딜 사용하던 상관은없지만 중앙값이 일반적임
left_arr, right_arr = [], [] # 피벗을 기준으로 정렬될 배열을 만들어줌
for i in range(len(x)):
if x[i] > x[pivot] and i != pivot:
left_arr.append(x[pivot])
# 피벗보다 지금 보는 값이 작을 경우
elif x[i] <= x[pivot] and i != pivot:
right_arr.append(x[pivot])
# x 배열에 pivot도 포함이 되어있으므로 반드시 pivot 인덱스는 제외해주어야 함.
return quick_sort(left_arr) + [x[pivot]] + quick_sort(right_arr)
위의 문제를 해결하기 위해서 memoization 기법이라던지 다양한 해결방법을 도입한다. 특히 재귀식이 아니면 구현하기 까다로운 dp나 dfs 문제들은 stack이나 memoization 을 이용해서 재귀식을 구현하고, 최적화를 통해 성능을 챙기는 방법을 사용한다.
위에서 함수 호출 자체가 굉장히 오버헤드가 큰 작업이라고 했고, 이 때문에 재귀 함수가 비효율적이라고 언급했다. 이를 해결하기 위한 또 하나의 방법이 tail recursion이다.
Tail recursion이란, 재귀 함수에서, 재귀 호출이 마지막에 단 한번 수행되는 것을 의미한다.
구체적으로 얘기하자면, 재귀 함수를 들여다보면 return 문이 두 개가 존재한다. 탈출 조건의 return 문과 일반 문들이 끝날 경우 다시 자신을 호출한 함수로 돌아가는 return 문이 존재한다. 이 return 문을 마지막에 선언했을 때, 어차피 함수는 이 리턴문을 마지막으로 해당 함수가 종료되고 사라질 것을 알고 있다. 때문에 함수 호출을 새로 하는 것이 아닌, 해당 함수를 재사용해서 그 즉시 함수를 overwrite 해버리는 기법이 tail recursion이다.
def fibonacci(n, a=0, b=1):
if n == 0:
return a
if n == 1:
return b
return fibonacci(n-1, b, a+b)
위 처럼 생겼을 때 컴파일러에서 tail recursion을 지원하면 해당 기능을 사용할 수 있다.