포스트

재귀와 분할정복 — 콜스택에 쌓이는 신뢰, 마스터 정리로 푸는 비용

재귀와 분할 정복의 사고법, 콜스택의 동작, 마스터 정리로 복잡도를 푸는 법, 카라츠바 곱셈까지. 문제가 쪼개지고 합쳐지는 과정을 애니메이션으로 봅니다.

재귀와 분할정복 — 콜스택에 쌓이는 신뢰, 마스터 정리로 푸는 비용

자기 자신을 믿는 함수

재귀(recursion)는 “더 작은 같은 문제를 풀 줄 안다고 믿고, 그 답으로 지금 문제를 푸는” 사고법입니다. factorial(n)을 풀 때 factorial(n-1)이 이미 정답을 준다고 믿으면, 남은 일은 n을 곱하는 것뿐입니다. 이 믿음이 성립하려면 두 가지가 필요합니다.

  • 기저 조건(base case): 더 쪼갤 수 없는 가장 작은 문제의 답. 이게 없으면 무한히 내려갑니다.
  • 재귀 단계(recursive step): 문제를 더 작은 같은 문제로 줄이는 규칙. 매번 기저에 가까워져야 합니다.
1
2
3
4
def factorial(n):
    if n <= 1:          # 기저 조건
        return 1
    return n * factorial(n - 1)   # 재귀 단계 — 한 걸음 작아진다

기저로 수렴하지 않는 재귀는 반드시 터집니다. 재귀를 읽을 때 늘 “이게 반드시 기저에 도달하는가?”를 먼저 확인하는 습관이 무한 재귀를 막습니다.

콜스택 — 재귀가 실제로 사는 곳

재귀는 마법이 아니라 콜스택(call stack) 위의 물리적 동작입니다. 함수를 호출할 때마다 매개변수·지역변수·복귀 주소를 담은 스택 프레임이 쌓이고(push), return할 때 꼭대기부터 걷힙니다(pop). 아래는 factorial(4)가 바닥까지 쌓였다가, 기저에서 값을 들고 거꾸로 풀리며 곱이 완성되는 과정입니다.

콜스택 (아래로 쌓임 → 위로 풀림) factorial(4) factorial(3) factorial(2) factorial(1) = 1 ← 기저 반환하며 곱셈: 1 → factorial(1)=1 2×1 → factorial(2)=2 3×2 → factorial(3)=6 4×6 → factorial(4)=24

여기서 두 가지가 따라옵니다. 첫째, 재귀 깊이만큼 스택 메모리를 먹습니다 — 깊이가 수만이면 스택 오버플로로 죽습니다. 둘째, 마지막 동작이 순수한 자기 호출(꼬리재귀, tail recursion)이면 컴파일러가 프레임을 재활용해 반복문으로 바꿀 수 있습니다. 다만 JVM·CPython은 꼬리 호출 최적화를 하지 않으므로, 깊은 재귀는 명시적 반복문이나 자체 스택으로 바꿔야 합니다.

분할정복 — 쪼개고, 풀고, 합친다

분할정복(divide and conquer)은 재귀를 전략으로 끌어올린 것입니다. 세 단계로 움직입니다.

  1. 분할(divide): 문제를 같은 종류의 더 작은 부분으로 나눈다.
  2. 정복(conquer): 부분을 재귀로 푼다(충분히 작으면 직접).
  3. 결합(combine): 부분의 답을 합쳐 전체 답을 만든다.

병합 정렬이 교과서적 예입니다. 배열을 절반씩 쪼개 바닥까지 내려갔다가, 정렬된 조각을 병합하며 거꾸로 올라옵니다. 아래는 그 “내려가며 쪼개고, 올라오며 합치는” 리듬입니다.

▼ 분할(divide) 병합(merge) ▲ [5 2 8 1] [5 2] [8 1] [5] [2] [8] [1] [2 5] [1 8] [1 2 5 8]

분할정복이 강력한 이유는 합치는 비용이 쌀 때 전체가 극적으로 빨라지기 때문입니다. 정렬을 절반씩 나눠 각각 정렬한 뒤 병합하면, 비교 정렬은 $O(n^2)$에서 $O(n\log n)$으로 떨어집니다. 같은 아이디어가 퀵정렬·이진 탐색·고속 푸리에 변환·행렬 곱(Strassen)까지 관통합니다.

마스터 정리 — 재귀의 비용을 즉답하는 공식

분할정복의 비용은 점화식(recurrence) 으로 표현됩니다.

\[T(n) = a\,T\!\left(\frac{n}{b}\right) + f(n)\]
  • $a$: 부분 문제의 개수
  • $b$: 한 단계에서 문제 크기가 줄어드는 배수
  • $f(n)$: 분할·결합에 드는 단계당 추가 비용

마스터 정리는 $f(n)$과 $n^{\log_b a}$의 크기를 비교해 $T(n)$을 세 경우로 즉답합니다.

경우조건결론직관
1$f(n) = O(n^{\log_b a - \epsilon})$$T(n)=\Theta(n^{\log_b a})$잎(leaf)이 지배
2$f(n) = \Theta(n^{\log_b a})$$T(n)=\Theta(n^{\log_b a}\log n)$층마다 균등 → $\log n$배
3$f(n) = \Omega(n^{\log_b a + \epsilon})$$T(n)=\Theta(f(n))$뿌리(root)가 지배

병합 정렬은 $a=2,\,b=2,\,f(n)=\Theta(n)$이고 $n^{\log_2 2}=n$이라 경우 2 → $\Theta(n\log n)$. 한눈에 나옵니다. 이진 탐색은 $a=1,\,b=2,\,f(n)=\Theta(1)$ → $\Theta(\log n)$.

flowchart TD
    A["T(n) = a·T(n/b) + f(n)"] --> B{"f(n) vs n^(log_b a)"}
    B -->|"f가 작다"| C["경우1: Θ(n^log_b a)"]
    B -->|"같다"| D["경우2: Θ(n^log_b a · log n)"]
    B -->|"f가 크다"| E["경우3: Θ(f(n))"]

카라츠바 — $a$를 줄여 차수를 낮추다

마스터 정리의 위력은 $a$를 줄이면 지수가 내려간다는 통찰을 줍니다. 두 $n$자리 수를 곱할 때, 초등학교 방식은 $\Theta(n^2)$입니다. 카라츠바(Karatsuba)는 수를 반으로 쪼개 곱셈 4번이 필요해 보이는 것을 3번으로 줄입니다($x=x_1 B + x_0$로 놓고 $x_1y_0+x_0y_1$을 한 번의 곱으로 추출).

\[T(n) = 3\,T\!\left(\frac{n}{2}\right) + \Theta(n) \;\Rightarrow\; \Theta\!\left(n^{\log_2 3}\right) \approx \Theta(n^{1.585})\]

곱셈 횟수 $a$를 4→3으로 줄인 것만으로 지수가 2→1.585로 떨어집니다. “결합 비용을 늘려서라도 부분 문제 수를 줄인다”는 분할정복 설계의 정수입니다.

프로덕션에서 마주치는 함정

함정증상해법
기저 누락·수렴 실패무한 재귀 → StackOverflowError기저 조건과 “매번 작아지는가” 검증
깊은 재귀입력 수만 깊이에서 스택 폭발(JVM 기본 ~512KB)반복문·명시적 스택으로 변환, 또는 스택 크기↑
중복 부분 문제 재계산피보나치 재귀가 $O(2^n)$메모이제이션 → 동적계획법
꼬리재귀 믿음“꼬리재귀니 괜찮겠지” → JVM/CPython은 미최적화언어 지원 확인, 안 되면 루프로
결합 비용 과소평가분할은 빠른데 merge가 $O(n^2)$$f(n)$을 정확히 세고 마스터 정리 재적용

면접/리뷰 단골 질문

  • Q. 재귀와 반복의 차이? → 재귀는 콜스택에 상태를 쌓아 표현력이 좋지만 스택 메모리·호출 비용이 든다. 반복은 메모리 상수지만 코드가 장황할 수 있다. 트리·분할정복은 재귀가 자연스럽다.
  • Q. 꼬리재귀가 뭐고 왜 중요? → 마지막 동작이 순수 자기 호출이면 프레임 재활용으로 반복문화 가능. 단 JVM·CPython은 미지원이라 깊은 재귀는 직접 루프로 바꿔야 한다.
  • Q. 마스터 정리 세 경우? → $f(n)$과 $n^{\log_b a}$ 비교. 잎 지배(1)·균등(2, $\log n$배)·뿌리 지배(3). 병합 정렬은 경우 2 → $\Theta(n\log n)$.
  • Q. 카라츠바가 $n^{1.585}$인 이유? → 곱셈을 4→3번으로 줄여 $a=3,b=2$ → $n^{\log_2 3}$. 부분 문제 수가 지수를 결정.
  • Q. 분할정복이 항상 빠른가? → 아니다. 결합 비용 $f(n)$이 크면(경우 3) 이득이 사라진다. 분할·결합이 싸야 이긴다.

정리

  • 재귀는 기저 조건 + 작아지는 재귀 단계로 성립하며, 실제로는 콜스택에 프레임을 쌓았다 푸는 물리적 동작이다.
  • 분할정복은 분할→정복→결합의 전략. 결합이 쌀 때 $O(n^2)$를 $O(n\log n)$으로 끌어내린다.
  • 마스터 정리 $T(n)=aT(n/b)+f(n)$는 $f(n)$과 $n^{\log_b a}$ 비교로 비용을 즉답한다.
  • 깊은 재귀의 스택 폭발과 중복 계산은 각각 반복문화와 동적계획법으로 해결한다.

이전 글은 복잡도와 분할상환이었습니다. 다음 글에서는 재귀가 가장 자연스럽게 살아 숨 쉬는 자료구조, 트리와 균형 트리로 들어갑니다.

</content> </invoke>

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 알고리즘 A-Z (20편)

  1. 복잡도 분석 — Big-O, 점근 표기, 그리고 분할상환이라는 거짓말 같은 진실
  2. 배열과 연결 리스트 — 같은 O(n)인데 왜 한쪽이 수십 배 느린가
  3. 스택·큐·힙 — 접근 규칙 하나가 만드는 강력함
  4. 해시 테이블 — 평균 O(1)의 비밀과 그 평균이 깨지는 순간
  5. 비교 정렬 — 퀵·머지·힙, 그리고 Ω(n log n)이라는 넘을 수 없는 벽
  6. 선형 시간 정렬 — 비교를 버리면 Ω(n log n) 벽이 사라진다
  7. 이진 탐색 — 반씩 버리는 마법, 그리고 "답을 이분하라"
  8. 재귀와 분할정복 — 콜스택에 쌓이는 신뢰, 마스터 정리로 푸는 비용 지금 보는 글
  9. 이진 탐색 트리와 균형 — 한쪽으로 쏠리는 트리를 회전으로 바로 세우기
  10. 그래프 순회 — BFS의 물결, DFS의 깊이, 그리고 위상정렬
  11. 최단 경로 — 다익스트라의 확신, A*의 직감, 벨만-포드의 인내
  12. 최소 신장 트리와 Union-Find — 가장 싸게 잇기, 그리고 거의 O(1)의 마법
  13. 동적계획법 — 같은 계산을 두 번 하지 않는다는 단 하나의 원칙
  14. 그리디 — 눈앞의 최선만 집었는데 전체가 최적일 때
  15. 문자열 매칭 — 어긋났을 때 얼마나 똑똑하게 다시 시작하는가
  16. 확률적 자료구조 — 정확함을 메모리와 맞바꾸는 기술 (Bloom·HyperLogLog·Count-Min)
  17. 일관된 해싱 — 노드가 들고날 때 데이터를 통째로 옮기지 않는 법
  18. 합의 알고리즘 — 죽는 서버들 사이에서 하나의 진실을 정하는 법 (Raft·Paxos)
  19. 레이트 리미팅과 스트리밍 알고리즘 — 폭주를 막고, 끝없는 흐름에서 표본을 뽑다
  20. 의미로 검색하다 — 벡터 검색(ANN·HNSW)과 PageRank, 그리고 시리즈의 끝