알고리즘 입문
개념 배우기직접 풀어보기
놀면 뭐 하니,
그냥 하는거야.

나를 다시 부른다! 재귀

코딩냥

안녕, 나 코딩냥이다냥! 3장에서 반복문을 배웠지? 오늘은 반복의 다른 얼굴을 배운다냥. 함수가 자기 자신을 부르는 거라냥. 처음엔 머리가 어질어질하지만, 익숙해지면 반복문으로는 짜기 힘든 것들이 술술 풀린다냥.

오늘의 학습 목표

  • 재귀의 두 부분 — 멈추는 조건과 자기 호출 — 을 나눠 쓴다.
  • 문제를 더 작은 같은 문제로 쪼개는 사고방식을 익힌다.
  • 재귀가 느려지는 이유와 고치는 법을 안다.
  • 재귀로 모든 경우를 만들어내는 방법을 익힌다.

1단계 · 재귀는 두 부분뿐이라냥 (7분)

코딩냥

재귀 함수는 늘 두 조각으로 되어 있다냥. 이것만 기억하면 절반은 끝이라냥.

def countdown(n):
    if n == 0:          # 1. 멈추는 조건
        print("끝")
        return
    print(n)
    countdown(n - 1)    # 2. 더 작은 문제로 자기 호출

비유로 이해하기 — 러시아 인형

인형을 열면 안에 더 작은 인형이 있다냥. 또 열면 더 작은 인형이 있고. 언젠가 더 안 열리는 가장 작은 인형이 나온다냥. 그게 멈추는 조건이라냥.

재귀의 부분인형으로 치면
멈추는 조건가장 작은 인형
자기 호출하나 열어서 안쪽 보기
없으면?영원히 연다

멈추는 조건을 빼먹으면 프로그램이 죽는다냥.

파이썬은 재귀가 1000번쯤 쌓이면 RecursionError 를 낸다냥. 무한 반복처럼 멈추지 않는 게 아니라 에러로 터진다냥.

그리고 자기 호출은 반드시 더 작은 문제여야 한다냥. f(n) 안에서 f(n) 을 부르면 영원히 작아지지 않는다냥.

2단계 · 반복문과 뭐가 다르냥 (7분)

코딩냥

카운트다운은 반복문으로도 되는데 왜 재귀를 쓰냥? 솔직히 그건 반복문이 낫다냥. 재귀가 빛나는 건 갈래가 둘 이상일 때라냥.

# 길이 n 인 이진 문자열을 전부 만든다냥
def go(cur):
    if len(cur) == n:
        print(cur)
        return
    go(cur + "0")      # 0을 붙이는 갈래
    go(cur + "1")      # 1을 붙이는 갈래
핵심자기 호출이 두 번이면 반복문으로는 어렵다냥

위 코드는 자기를 두 번 부른다냥. 0을 붙인 경우와 1을 붙인 경우를 각각 끝까지 따라간다냥.

이걸 반복문으로 짜려면 직접 스택을 만들어야 한다냥. 재귀로는 두 줄이라냥.

갈래가 여러 개로 뻗는 문제 = 재귀 라고 기억하자냥.

n = 3 일 때 어떤 순서로 나오는지 손으로 따라가보자냥.

000, 001, 010, 011, 100, 101, 110, 111

0 을 먼저 부르니 0 으로 시작하는 게 전부 먼저 나온다냥. 부르는 순서를 바꾸면 결과 순서도 바뀐다냥.

3단계 · 하노이 탑 (10분)

코딩냥

재귀의 고전이라냥. 처음 보면 어렵지만, "내가 직접 옮기지 않는다" 고 생각하면 갑자기 쉬워진다냥.

원판 n개를 1번에서 3번으로 옮기고 싶다냥. 이렇게 생각한다냥.

발상위의 n-1개는 누가 알아서 옮겨준다고 치자냥
  1. 위에 있는 n-1개를 2번 기둥으로 옮긴다 (누가 해준다고 치자냥)
  2. 제일 큰 원판 하나를 1번 → 3번 으로 옮긴다 (이건 내가 한다냥)
  3. 2번에 있는 n-1개를 3번으로 옮긴다 (또 누가 해준다고 치자냥)

"누가 해준다"는 게 바로 자기 호출이라냥. 내가 할 일은 가운데 한 줄 뿐이라냥.

def hanoi(k, start, end, via):
    if k == 0:
        return
    hanoi(k - 1, start, via, end)     # 위 k-1개를 via 로
    print(start, end)                 # 큰 거 하나를 옮긴다냥
    hanoi(k - 1, via, end, start)     # via 의 k-1개를 end 로

기둥 세 개의 역할이 매번 바뀐다는 게 헷갈리는 부분이라냥.

첫 번째 호출에서는 end 가 경유지가 되고, 두 번째 호출에서는 start 가 경유지가 된다냥. 인자 순서를 잘못 넘기면 이동 순서가 통째로 틀린다냥.

손으로 n = 2 를 따라가보면 금방 이해된다냥.

4단계 · 재귀가 느려질 때 (12분)

코딩냥

재귀에는 큰 함정이 하나 있다냥. 피보나치를 재귀로 짜보면 바로 만난다냥.

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

깔끔해 보이지만 fib(50) 을 부르면 끝나지 않는다냥.

이유는 같은 값을 몇 번이고 다시 계산하기 때문이라냥.

추적fib(5) 가 fib(2) 를 몇 번 부르냥
fib(5) ├ fib(4) │ ├ fib(3) │ │ ├ fib(2) ← │ │ └ fib(1) │ └ fib(2) ← └ fib(3) ├ fib(2) ← └ fib(1)

fib(2) 를 세 번이나 부른다냥. n 이 커지면 이 중복이 폭발한다냥. fib(50) 은 호출이 수백억 번이라냥.

고치는 법 — 한 번 계산한 건 적어두자냥

memo = {}

def fib(n):
    if n < 2:
        return n
    if n in memo:          # 이미 계산했으면 꺼내 쓴다냥
        return memo[n]
    memo[n] = fib(n - 1) + fib(n - 2)
    return memo[n]

이걸 메모이제이션이라고 한다냥. 5장에서 배운 딕셔너리가 여기서 쓰인다냥.

파이썬에는 이걸 자동으로 해주는 도구도 있다냥.

from functools import lru_cache

@lru_cache(None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

함수 위에 한 줄 붙이면 끝이라냥.

5단계 · 절반씩 줄이는 재귀 (10분)

코딩냥

6장에서 이분 탐색을 배웠지? 재귀에도 같은 아이디어를 쓸 수 있다냥. a 를 b번 곱하는 걸 훨씬 빠르게 하는 방법이라냥.

2^10 을 구한다고 하자냥. 10번 곱하는 대신 이렇게 한다냥.

지수를 반으로 쪼갠다냥

식다시 쓰면
2^10(2^5) × (2^5)
2^5(2^2) × (2^2) × 2
2^2(2^1) × (2^1)

한 번 쪼갤 때마다 지수가 절반이 된다냥. 그래서 b번이 아니라 약 log b 번이면 끝난다냥.

def power(a, b):
    if b == 0:
        return 1
    half = power(a, b // 2)     # 절반을 한 번만 계산한다냥
    if b % 2 == 0:
        return half * half
    else:
        return half * half * a

power(a, b // 2) 를 변수에 담아 한 번만 부르는 것이 핵심이라냥.

return power(a, b//2) * power(a, b//2)    # 이러면 안 된다냥!

이렇게 쓰면 같은 걸 두 번 계산해서 결국 b번 곱하는 것과 같아진다냥. 피보나치에서 본 그 함정이라냥.

오늘 배운 내용 정리

코딩냥

먼저 스스로 답해보고, "정답 보기"를 눌러서 맞는지 확인해보자냥!

Q1.재귀 함수가 반드시 가져야 하는 두 부분은 뭐냥?
Q2.반복문 대신 재귀를 쓰면 좋은 상황은 언제냥?
Q3.순수 재귀 피보나치가 왜 느리냥?
Q4.그 문제를 어떻게 고치냥?
Q5.power 에서 half 를 변수에 담는 이유가 뭐냥?

이제 직접 풀어볼 차례다냥

코딩냥

오늘 문제는 다섯 개라냥. 12번은 재귀로 만들어내기, 34번은 느려지는 걸 막기, 5번은 모든 경우를 살펴보기라냥. 손으로 작은 값을 따라가보는 것이 오늘의 최고 무기라냥. 가보자냥~