재귀 (Recursion)

재귀는 컴퓨터 과학과 수학에서 매우 중요한 문제 해결 기법으로, 자기 자신을 호출하는 함수나 알고리즘을 통해 복잡한 문제를 더 작고 동일한 형태의 하위 문제로 나누어 해결하는 방법이다.

재귀는 복잡한 문제를 더 작고 관리하기 쉬운 부분으로 나누는 강력한 문제 해결 기법이다.
재귀적 접근법은 많은 컴퓨터 과학 알고리즘과 자료구조의 기반이 된다.
효과적인 재귀 알고리즘을 작성하려면 기저 조건을 명확히 정의하고, 문제를 적절히 분해하며, 필요한 경우 최적화 기법을 사용해야 한다.

재귀에 익숙해지면 이전에는 복잡하게만 보였던 많은 문제를 우아하고 효율적으로 해결할 수 있습니다.

재귀의 기본 개념

재귀는 문제를 자기 자신과 동일하지만 더 작은 규모의 문제로 분해하여 해결하는 접근법이다.

이러한 접근법은 다음과 같은 두 가지 핵심 요소로 구성된다:

  1. 기저 조건(Base Case): 재귀 호출이 종료되는 조건으로, 더 이상 자기 자신을 호출하지 않고 직접 답을 반환한다.
  2. 재귀 단계(Recursive Step): 문제를 더 작은 하위 문제로 나누고 자기 자신을 호출하여 해결하는 단계이다.

재귀의 작동 원리

재귀 함수가 호출될 때마다 새로운 함수 호출 스택 프레임이 생성되며, 이 프레임에는 해당 함수 호출의 매개변수와 지역 변수가 저장된다. 함수가 종료되면 해당 스택 프레임이 제거되고 이전 함수 호출로 돌아간다.

재귀 예제: 팩토리얼 계산

팩토리얼은 재귀의 고전적인 예.
n!은 n부터 1까지의 모든 정수의 곱.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def factorial(n):
    # 기저 조건: n이 0 또는 1이면 1을 반환
    if n <= 1:
        return 1
    # 재귀 단계: n * (n-1)!
    else:
        return n * factorial(n-1)

# 팩토리얼 실행 예시
result = factorial(5)  # 5! = 5 * 4 * 3 * 2 * 1 = 120
print(result)  # 출력: 120

이 예제에서 n <= 1은 기저 조건이고, n * factorial(n-1)은 재귀 단계입니다.

실행과정

1
2
3
4
5
factorial(5) → 5 * factorial(4)
                  → 4 * factorial(3)
                      → 3 * factorial(2)
                          → 2 * factorial(1)
                              → 1

이 함수는 n이 0일 때 1을 반환하는 기본 조건과, n과 factorial(n-1)을 곱하는 재귀 호출로 구성되어 있다.

재귀적 사고방식

재귀적으로 생각하는 것은 처음에는 어려울 수 있지만, 다음과 같은 접근 방식이 도움이 된다:

  1. 문제 분해: 주어진 문제를 동일한 형태의 더 작은 하위 문제로 나눈다.
  2. 기저 조건 식별: 더 이상 분해할 수 없는 가장 작은 문제(기저 조건)를 식별한다.
  3. 재귀 관계 정의: 원래 문제와 하위 문제 사이의 관계를 정의한다.

재귀의 장단점

장점

단점

고급 재귀 기법

  1. 꼬리 재귀(Tail Recursion)
    꼬리 재귀는 재귀 호출이 함수의 마지막 연산인 경우이다.
    이는 컴파일러가 최적화하여 스택 사용을 줄일 수 있다.

    1
    2
    3
    4
    5
    
    def factorial_tail(n, accumulator=1):
        if n <= 1:
            return accumulator
        else:
            return factorial_tail(n-1, n * accumulator)
    
  2. 메모이제이션(Memoization)
    메모이제이션은 이미 계산한 결과를 저장하여 동일한 계산을 여러 번 수행하지 않도록 하는 기법.

    1
    2
    3
    4
    5
    6
    7
    
    def fibonacci_memo(n, memo={}):
        if n in memo:
            return memo[n]
        if n <= 1:
            return n
        memo[n] = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo)
        return memo[n]
    

실제 응용 사례

재귀는 다음과 같은 다양한 문제 해결에 활용된다:

  1. 트리와 그래프 탐색: 깊이 우선 탐색(DFS)은 본질적으로 재귀적이다.
  2. 분할 정복 알고리즘: 퀵 정렬, 병합 정렬과 같은 정렬 알고리즘은 재귀를 사용한다.
  3. 동적 프로그래밍: 많은 동적 프로그래밍 문제는 재귀로 표현할 수 있다.
  4. 백트래킹: 스도쿠 해결, N-퀸 문제와 같은 백트래킹 알고리즘은 재귀를 사용한다.

재귀 예제: 하노이 탑

하노이 탑은 재귀의 고전적인 예제.
세 개의 기둥과 크기가 다른 n개의 원판이 있으며, 목표는 모든 원판을 첫 번째 기둥에서 세 번째 기둥으로 옮기는 것.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def hanoi_tower(n, source, auxiliary, target):
    # 기저 조건: 원판이 1개만 있으면 직접 옮깁니다
    if n == 1:
        print(f"원판 1을 {source}에서 {target}으로 이동")
        return
    
    # 재귀 단계 1: n-1개의 원판을 source에서 auxiliary로 이동
    hanoi_tower(n-1, source, target, auxiliary)
    
    # 가장 큰 원판을 source에서 target으로 이동
    print(f"원판 {n}{source}에서 {target}으로 이동")
    
    # 재귀 단계 2: n-1개의 원판을 auxiliary에서 target으로 이동
    hanoi_tower(n-1, auxiliary, source, target)

# 실행 예시: 3개의 원판을 A에서 C로 이동
hanoi_tower(3, 'A', 'B', 'C')

재귀와 반복의 비교

모든 재귀 알고리즘은 반복(loop)으로 변환할 수 있다.
재귀 대신 반복을 사용하면 메모리 사용을 줄일 수 있지만, 코드가 더 복잡해질 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 재귀 방식의 팩토리얼
def factorial_recursive(n):
    if n <= 1:
        return 1
    return n * factorial_recursive(n-1)

# 반복 방식의 팩토리얼
def factorial_iterative(n):
    result = 1
    for i in range(1, n+1):
        result *= i
    return result

재귀적 자료구조

재귀는 자료구조에도 적용된다.
연결 리스트, 트리, 그래프와 같은 자료구조는 재귀적으로 정의되며, 이러한 자료구조를 처리하는 알고리즘도 종종 재귀적.

예를 들어, 이진 트리의 순회:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

def inorder_traversal(root):
    if root is None:
        return []
    
    # 왼쪽 하위 트리 -> 루트 -> 오른쪽 하위 트리 순으로 순회
    return inorder_traversal(root.left) + [root.value] + inorder_traversal(root.right)

재귀의 응용

재귀는 다양한 알고리즘과 자료구조에서 활용된다:

  1. 정렬 알고리즘: 퀵 정렬, 병합 정렬
  2. 탐색 알고리즘: 깊이 우선 탐색(DFS)
  3. 동적 프로그래밍: 피보나치 수열 계산
  4. 수학적 문제: 하노이의 탑, 조합 계산

실제 예시

  1. HTML DOM 순회
1
2
3
4
5
6
7
def traverse_dom(element):
    # 현재 요소 처리
    print(element.tag_name)
    
    # 모든 자식 요소에 대해 재귀 호출
    for child in element.children:
        traverse_dom(child)
  1. 폴더 내 파일 찾기
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import os

def find_files(folder_path, extension):
    files = []
    
    # 현재 폴더의 내용물 확인
    for item in os.listdir(folder_path):
        full_path = os.path.join(folder_path, item)
        
        if os.path.isfile(full_path):  # 파일인 경우
            if item.endswith(extension):
                files.append(full_path)
        else:  # 폴더인 경우
            # 재귀적으로 하위 폴더 검색
            files.extend(find_files(full_path, extension))
            
    return files

# 사용 예
pdf_files = find_files("C:/Documents", ".pdf")
  1. 디렉토리 크기 계산
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def get_folder_size(folder_path):
    total_size = 0
    
    for item in os.listdir(folder_path):
        item_path = os.path.join(folder_path, item)
        
        if os.path.isfile(item_path):
            total_size += os.path.getsize(item_path)
        else:
            # 폴더인 경우 재귀적으로 크기 계산
            total_size += get_folder_size(item_path)
            
    return total_size

참고 및 출처