문서 읽는 데 95분 · E3

E-3: 그리디와 분할정복 — 영리하게 고르고, 쪼개서 정복한다

목차 55
전체 20강 중 15강 · 자료구조·알고리즘
난이도 · 중급선수지식파이썬 기초

ℹ️코딩테스트의 관문 — 자료구조·알고리즘을 직접 구현하고 유형별로 풀어요. 언어 하나(파이썬·자바)를 뗀 다음에 권해요.

안녕하세요, 여러분의 알고리즘 길잡이 홍순구 튜터입니다. 지난 시간엔 그래프 위를 걷는 두 걸음걸이 DFS와 BFS를 익혔습니다. 방문 체크로 무한 루프를 막고, 깊게 파고 넓게 퍼지며 미로와 섬을 훑었죠. E-1의 완전탐색부터 여기까지, 지금껏 우리의 정신은 한결같았습니다. "영리한 지름길이 없으면 다 뒤진다."

오늘은 그 반대편으로 넘어갑니다. 다 뒤지지 않고 영리하게 고르고 쪼개는 두 사고법이에요. 그리디(greedy, 탐욕법)는 매 순간 눈앞에서 가장 좋아 보이는 것을 집습니다. 거스름돈을 줄 때 500원짜리부터 집는 그 직관 그대로예요. 다만 오늘의 진짜 승부는 구현이 아닙니다. "눈앞의 최선이 정말 전체의 최선인가"를 따져 보는 것 — 이게 그리디의 전부입니다. 뒤이어 만날 분할정복(divide and conquer)은 문제를 반으로 쪼개 정복하는 방식인데, D-1에서 이미 만난 병합 정렬과 퀵 정렬이 바로 그 대표 사례라 반갑게 회수하게 될 거예요.

텍스트
 오늘의 여정 — 다 뒤지지 않고, 영리하게 고르고 쪼갠다

   그리디(탐욕법)       매 순간 눈앞의 최선을 집고 되돌아보지 않는다
     │
     ├─ 교환 논법       "그리디의 선택으로 바꿔치기해도 손해가 없다"
     │                  코드는 열 줄, 어려운 건 정당성 증명이다
     │
     ├─ 되는 그리디     회의실 배정 — 종료 시각 정렬이면 최적이 보장된다
     │                  기준을 바꾸면 곧장 틀린 답이 나온다
     │
     └─ 안 되는 그리디  거스름돈 — 동전 체계가 받쳐 줄 때만 최적이다
                        [1,3,4]로 6원을 거슬러 보면 그대로 깨진다
     
   분할정복             문제를 반으로 쪼개 각각 풀고 합친다
     ├─ D-1 회수        병합 정렬·퀵 정렬이 이미 이 사고였다
     └─ 빠른 거듭제곱   지수를 반씩 접어 O(n)을 O(log n)으로

💡 오늘 수업의 핵심 — "그리디는 눈앞의 최선을 집어 빠르지만 그 선택이 전체 최적이라는 보장은 따로 증명해야 하고, 분할정복은 문제를 반으로 접어 O(n)을 O(log n)으로 끌어내린다"

🎯 학습 목표

  • 그리디가 완전탐색과 무엇이 다른지 이해하고, 교환 논법으로 "이 기준으로 집어도 최적인가"를 따져 본다.
  • 회의실 배정에서 정렬 기준이 정답을 좌우한다는 걸 확인하고, 거스름돈 반례로 그리디가 깨지는 순간을 직접 본다.
  • 분할정복으로 문제를 반씩 접어 O(log n)을 끌어내고, D-1의 병합·퀵 정렬이 같은 사고였음을 회수한다.

Step 1: "큰 동전부터 집는다" (그리디 개념)

1370원을 거슬러 줘야 합니다. 여러분은 어떻게 하시겠어요? 아마 고민도 안 하고 500원짜리 두 개를 먼저 집을 겁니다. 남은 370원엔 100원짜리 세 개, 남은 70원엔 50원 하나, 남은 20원엔 10원 두 개. 끝났습니다. 이 과정에서 여러분은 단 한 번도 "아까 500원 말고 100원을 집었어야 했나?" 하고 되돌아보지 않았어요.

바로 이게 그리디(greedy, 탐욕법)입니다. 정의하면 이렇습니다. 매 순간 지금 가장 좋아 보이는 것을 집고, 한 번 집은 선택은 되돌아보지 않는다. 이름이 탐욕스러운 이유가 여기 있어요. 멀리 내다보지 않고 눈앞의 이득만 챙깁니다.

이게 왜 대단한 전략인지는 E-1의 완전탐색과 나란히 놓아 보면 확 드러납니다.

텍스트
 완전탐색(E-1) — 매 선택마다 갈라진다

           o
          / \
         o   o          선택 하나에 가지가 둘로 갈라진다
        / \ / \         깊이가 n이면 잎이 2ⁿ개
       o  o o  o
      /\ /\ /\ /\       n=20이면 백만, n=30이면 십억 갈래
      oo oo oo oo       다 뒤져야만 최선을 안다

 그리디 — 갈라지지 않고 한 줄기로 내려간다

           o            매 단계에서 '지금 가장 좋아 보이는' 가지 하나만 고른다
           |            나머지 가지는 아예 쳐다보지도 않는다
           o
           |            깊이 n을 그냥 n걸음에 내려온다
           o
           |            되돌아오지 않으니 2ⁿ이 n으로 접힌다
           o

완전탐색이 나무 전체를 뒤졌다면, 그리디는 뿌리부터 잎까지 한 줄기만 훑고 내려옵니다. 갈라지지 않으니 폭발이 없어요. 백트래킹처럼 되돌아 나오지도 않습니다. 그래서 압도적으로 빠릅니다.

그리디의 빅오 — 대개 정렬이 지배한다

그럼 그리디는 얼마나 빠를까요? 골격을 뜯어 보면 보통 두 단계입니다. 먼저 "무엇이 좋아 보이는지" 순서를 매기고(정렬), 그다음 앞에서부터 하나씩 집습니다.

텍스트
 그리디의 표준 골격

   ① 정렬       "좋아 보이는 순서"로 후보를 줄 세운다      O(n log n)
   ② 훑기       앞에서부터 하나씩 보고 집을지 말지 결정    O(n)
   ③ 끝         되돌아보지 않는다 — 다시 훑는 일이 없다
                                     ────────────────────
                                     합치면 O(n log n)

②의 훑기는 후보를 한 번만 보고 지나가니 O(n)뿐입니다. 그래서 전체 시간 복잡도는 ①의 정렬이 지배해 대개 O(n log n)이 돼요. A-1의 "1초에 1억 연산" 잣대로 보면, n이 10만이든 100만이든 정렬 한 번은 여유롭게 통과합니다. 완전탐색의 O(2ⁿ)이 n=30에서 이미 무너졌던 걸 떠올리면 차원이 다른 속도죠.

여기까지만 들으면 그리디는 만능처럼 보입니다. 빠르고, 코드는 열 줄이면 끝나고, 직관적이니까요. 그런데 바로 여기에 함정이 있습니다. 되돌아보지 않는다는 건, 한 번 잘못 집으면 영영 회복할 수 없다는 뜻이거든요. 완전탐색은 느려도 답은 반드시 맞혔습니다. 그리디는 빠른 대신 틀린 답을 낼 수 있어요. 이 트레이드오프가 오늘의 핵심입니다.

💡 한 줄 정리

그리디는 매 순간 눈앞의 최선을 집고 되돌아보지 않는 전략이다. 완전탐색이 나무 전체를 뒤진다면 그리디는 한 줄기만 훑고 내려와, 2ⁿ이 n으로 접힌다. 골격은 "정렬 → 한 번 훑기"라 시간은 대개 정렬이 지배해 O(n log n)이다. 대신 잘못 집어도 되돌릴 수 없어, 답이 틀릴 수 있다.

🙋 학생 질문 — "그리디가 항상 옳다면 완전탐색은 왜 배웠나요? 다 그리디로 풀면 되잖아요."

바로 그 "항상 옳다면"이 성립하지 않아서예요. 그리디는 문제를 가려 씁니다. 어떤 문제에선 눈앞의 최선을 집는 게 정확히 전체 최적으로 이어지지만, 어떤 문제에선 지금 최선이 나중을 망칩니다. Step 4에서 거스름돈으로 그 순간을 직접 보실 거예요.

관계를 이렇게 정리하면 편합니다. 완전탐색은 느리지만 항상 맞는 안전망이고, 그리디는 빠르지만 조건이 맞을 때만 맞는 지름길입니다. 지름길이 있는 줄 알고 뛰어들었는데 실은 없었다면, 코딩테스트에선 "몇 개 케이스만 통과" 같은 애매한 결과로 돌아와요. 시간 초과보다 더 잡기 어려운 함정이죠.

그래서 실전 순서는 이렇습니다. 그리디로 풀릴 것 같으면 먼저 "이 기준으로 집어도 최적인가"를 따져 보고, 확신이 서면 그리디로. 확신이 안 서고 입력이 작으면 완전탐색으로 안전하게. 이 판단을 위해 완전탐색을 먼저 배운 겁니다. 다음 Step에서 그 "따져 보는 법"을 배웁니다.


Step 2: "눈앞의 최선이 전체의 최선인가" (교환 논법)

솔직하게 말씀드릴게요. 그리디에서 코드는 가장 쉬운 부분입니다. 정렬 한 줄, 반복문 한 줄, 조건문 한 줄이면 끝나요. 진짜 어려운 건 그 앞에 있습니다. "내가 고른 이 기준으로 집어도 전체 최적이 나오는가?" 이걸 증명하는 것.

증명이라는 말에 겁먹지 마세요. 수학 시험을 보자는 게 아닙니다. 코딩테스트에서 필요한 건 "이 기준이 왜 안전한지 스스로 납득하는 30초"예요. 그 30초를 위한 표준 도구가 바로 교환 논법(exchange argument)입니다.

바꿔치기해도 손해가 없다면

아이디어는 이렇습니다. 우리는 정답(최적해)이 뭔지 모릅니다. 하지만 "어딘가에 최적해가 하나 있다"고 가정할 수는 있어요. 그 최적해를 손에 들고, 첫 선택을 그리디의 선택으로 바꿔치기해 봅니다. 바꿨는데도 답이 나빠지지 않는다면? 그리디의 선택을 포함한 것도 여전히 최적해라는 뜻이죠.

텍스트
 교환 논법 — 최적해의 첫 선택을 그리디의 선택으로 바꿔치기한다

   어떤 최적해 O :  [ x ][ 나머지 선택들 ... ]      x = O가 처음 고른 것
   그리디의 선택 :    g                            g = 그리디가 처음 고른 것

   ① x를 g로 바꿔치기한다     [ g ][ 나머지 선택들 ... ]

   ② 바꿔도 답이 나빠지지 않는다는 걸 보인다
         그러면 바꾼 것도 '여전히 최적해'다

   ③ 남은 문제(첫 선택을 뺀 나머지)에 같은 논리를 되풀이한다
         그리디의 두 번째 선택도, 세 번째 선택도 끼워 넣을 수 있다

   ④ 하나씩 다 끼워 넣으면 남는 건 그리디의 답 그 자체
         그리디의 답 = 최적해.  증명 끝.

핵심은 ②입니다. "바꿔도 손해가 없다" 이 한 문장만 보이면 나머지는 도미노처럼 넘어가요. 반대로 ②가 안 보이면, 그건 그리디가 틀렸다는 강력한 신호입니다.

반례 하나면 그리디는 무너진다

증명은 어렵지만, 반박은 놀랍도록 쉽습니다. 그리디가 틀렸다는 걸 보이려면 틀리는 입력 하나만 찾으면 되거든요. 이걸 반례(counterexample)라고 합니다.

텍스트
 증명과 반박의 비대칭

   "그리디가 맞다"를 보이려면     모든 입력에서 맞음을 논증해야 한다  (어렵다)
   "그리디가 틀리다"를 보이려면   틀리는 입력 딱 하나면 끝            (쉽다)

   그래서 실전 순서:
     ① 작은 반례를 몇 개 만들어 본다  하나라도 걸리면 그리디 폐기
     ② 아무리 해도 안 깨지면 그때 교환 논법으로 "왜 안 깨지나"를 확인

실전에서 이 순서가 훨씬 효율적입니다. 먼저 손으로 작은 입력을 몇 개 만들어 그리디를 부숴 보세요. 부서지면 그리디는 곧장 폐기고, 시간을 아낀 겁니다. 안 부서지면 그때 교환 논법으로 "왜 안 부서지는지"를 확인하고 자신 있게 제출하면 돼요.

다음 Step에서 회의실 배정을 만납니다. 회의 요청이 (시작, 종료)로 잔뜩 들어왔을 때 최대 몇 개를 넣을 수 있느냐는 문제인데, 여기서 "어떤 순서로 집을 것인가"라는 후보가 세 개나 나옵니다. 짧은 회의부터? 일찍 시작하는 것부터? 먼저 끝나는 것부터? 셋 중 둘은 반례로 곧장 무너지고, 하나만 교환 논법을 통과해요. 지금 배운 두 도구를 바로 써먹을 차례입니다.

💡 한 줄 정리

그리디의 진짜 어려움은 구현이 아니라 정당성이다. 교환 논법은 "어떤 최적해를 가져와도 그 첫 선택을 그리디의 선택으로 바꿔치기해서 손해가 없다면, 그리디도 최적"이라는 도구다. 증명은 어렵지만 반박은 반례 하나면 끝나니, 실전에선 작은 반례부터 던져 보고 안 깨질 때만 교환 논법으로 확인한다.

🙋 학생 질문 — "코딩테스트에서 진짜로 증명까지 하고 코드를 짜나요? 시간이 없을 것 같은데요."

정식 증명을 종이에 쓰지는 않습니다. 하지만 머릿속에서 30초짜리 축약판은 반드시 돌려요. 그리고 그 30초가 없으면 오히려 시간을 훨씬 크게 잃습니다.

이런 시나리오를 상상해 보세요. 그리디로 30분 걸려 코드를 다 짰습니다. 제출했더니 "일부 케이스 실패". 이제 뭘 해야 하죠? 코드 버그인지, 접근 자체가 틀린 건지 알 수가 없습니다. 디버깅으로 한 시간을 태우고 나서야 "아, 그리디가 애초에 틀린 접근이었네"를 깨닫는 거예요. 반면 코드를 짜기 전 30초 동안 작은 반례를 몇 개 던져 봤다면, 그 30초에 접근을 갈아탔을 겁니다.

그래서 실전의 30초는 이렇게 씁니다. 첫째, 아주 작은 입력(원소 2~3개)으로 손으로 계산해 그리디와 최적을 비교해 본다. 둘째, "지금 최선을 집었을 때 나중에 손해 볼 상황"을 일부러 상상해 본다. 셋째, 안 깨지면 "왜 이 기준이 손해를 안 보지?"를 한 문장으로 말해 본다. 이 한 문장이 나오면 그게 교환 논법의 축약판이고, 자신 있게 짜면 됩니다.


Step 3: "정렬하고 탐욕하기" (회의실 배정)

이제 실물을 봅시다. 회의실이 딱 하나 있는데, 회의 요청이 (시작 시각, 종료 시각) 형태로 잔뜩 들어왔습니다. 회의는 겹칠 수 없어요. 최대 몇 개를 넣을 수 있을까요?

코딩테스트에서 "회의실 배정"·"강의실 배정"이라는 이름으로 그렇게나 자주 나오는 유형입니다. 그리고 이 문제의 정답은 놀랍도록 짧아요.

Python
# algorithms/greedy_divide.py
def greedy_interval_scheduling(intervals):
    count = 0
    last_end = float("-inf")            # 아직 아무것도 안 집었다 — 어떤 시작보다도 이르게
    for start, end in sorted(intervals, key=lambda pair: pair[1]):   # 종료 시각 기준
        if start >= last_end:           # 앞 회의가 끝난 뒤에 시작한다 = 안 겹친다
            count += 1
            last_end = end              # 이 회의가 끝나는 시각이 새 기준선
    return count

Step 1에서 본 그리디의 표준 골격이 그대로 보이시죠? 정렬 한 줄, 훑기 한 번. last_end가 "마지막으로 집은 회의가 끝난 시각"을 들고 다니는 기준선입니다. 처음엔 아무것도 안 집었으니 어떤 시작 시각보다도 이르도록 float("-inf")(음의 무한대)로 둬요. 그러면 첫 회의는 무조건 통과합니다. 이후로는 새 회의의 시작이 기준선 이후면 집고, 기준선을 그 회의의 끝으로 옮깁니다.

전부 다 해서 여덟 줄. 그런데 이 여덟 줄에서 정답을 결정하는 건 딱 하나예요. key=lambda pair: pair[1] — 종료 시각으로 정렬한다는 것.

정렬 기준이 전부다

pair[1]은 (시작, 종료) 튜플의 두 번째 값, 즉 종료 시각입니다. 이 한 글자를 pair[0](시작 시각)으로 바꾸면 코드는 멀쩡히 돌지만 답이 틀립니다. Step 2에서 예고한 세 후보를 놓고 보죠.

정렬 기준 직관 결과
짧은 회의부터 "짧으니까 많이 들어가겠지" ❌ 틀림
일찍 시작하는 것부터 "먼저 온 순서대로" ❌ 틀림
먼저 끝나는 것부터 "빨리 비워 줄수록 좋다" 🌟 정답

세 후보 중 둘이 반례로 무너집니다. 회의 세 개로 직접 부숴 볼게요.

텍스트
 정렬 기준 함정 — 같은 회의 3개, 기준만 바꿨는데 답이 달라진다

   후보:  (0,10)   (1,2)   (3,4)

   시작 시각 정렬  (0,10)을 먼저 집는다
        0 |==================| 10      하나가 10시까지 방을 통째로 차지
        (1,2)·(3,4)는 전부 겹쳐 버려짐                답 1개

   종료 시각 정렬  (1,2)를 먼저 집는다
        1 |==| 2    3 |==| 4           작은 둘이 나란히 들어간다
        (0,10)은 이미 집은 것과 겹쳐 버려짐            답 2개

greedy_interval_scheduling([(0,10), (1,2), (3,4)])를 실행하면 2가 나옵니다. 종료 시각으로 정렬했으니까요. 만약 시작 시각으로 정렬했다면 (0,10)이라는 욕심쟁이 회의에 발목이 잡혀 1개밖에 못 넣습니다.

왜 "먼저 끝나는 것"이 안전한가

이제 Step 2의 교환 논법을 여기에 적용해 봅시다. 왜 종료 시각 기준이 손해를 안 볼까요?

한 문장으로 답이 됩니다. 먼저 끝날수록 남은 시간이 가장 길게 남고, 그 남은 시간은 다른 어떤 선택지의 남은 시간도 포함한다.

텍스트
 왜 손해가 없나 — 남은 시간의 포함 관계

   회의 g (가장 먼저 끝남, 끝 4)  :  g가 남긴 시간  4 ───────────── 끝
   회의 x (더 늦게 끝남,   끝 9)  :  x가 남긴 시간         9 ────── 끝
                                                   └─ x의 남은 시간은
                                                      g의 남은 시간에 통째로 들어간다

    x로 이후에 집을 수 있는 회의는, g로도 전부 집을 수 있다
    첫 선택을 x에서 g로 바꿔치기해도 개수가 줄지 않는다  (교환 논법 )
    그리디의 선택을 하나씩 다 끼워 넣어도 최적이 유지된다

x의 남은 시간이 g의 남은 시간 안에 완전히 들어가니, x로 가능한 미래는 g로도 전부 가능합니다. 그러니 첫 회의를 "가장 먼저 끝나는 것"으로 바꿔도 절대 손해가 없어요. 이게 바로 교환 논법 ②이고, 나머지는 도미노로 넘어갑니다. "먼저 끝나는 걸 집어서 손해 보는 경우가 없다" — 이 한 문장이 그리디의 정당성 전부입니다.

경계 규칙 하나 — 끝과 시작이 같으면?

코드의 조건이 start > last_end가 아니라 start >= last_end인 것, 눈에 걸리셨나요? 등호가 붙어 있습니다.

(1,3) 회의와 (3,5) 회의가 있다고 해 봅시다. 앞 회의가 3시에 끝나고 뒤 회의가 3시에 시작해요. 겹치는 걸까요? 안 겹칩니다. 앞 회의가 끝나는 순간 방이 비니, 뒤 회의가 이어서 쓸 수 있거든요. 그래서 등호를 포함해 start >= last_end로 씁니다. 이 한 글자를 놓치면 이어 붙일 수 있는 회의를 놓쳐 답이 하나씩 모자라요. 코테에서 "경계에서만 틀리는" 전형적인 실수입니다.

이제 진짜 문제로 돌려 봅시다. 회의 11개를 넣어 볼게요.

Python
meetings = [
    (1, 4), (3, 5), (0, 6), (5, 7), (3, 9),
    (5, 9), (6, 10), (8, 11), (8, 12), (2, 14), (12, 16),
]

greedy_interval_scheduling(meetings)4를 돌려줍니다. 어떻게 4개가 골라졌는지 따라가 볼게요.

텍스트
 종료 시각이 이른 것부터 집으면 — 11개 중 4개가 들어간다

   시각 0 ────────────────────────────────────── 16

   (1,4)     |--|                        집는다  (끝 4가 가장 이르다)
   (5,7)          |-|                    집는다  (5 >= 4 이니 안 겹침)
   (8,11)              |---|             집는다  (8 >= 7)
   (12,16)                    |-----|    집는다  (12 >= 11)

   나머지 7개 — (3,5)(0,6)(3,9)(5,9)(6,10)(8,12)(2,14) 는
   이미 집은 회의와 겹쳐 전부 버려진다.

(2,14)처럼 12시간짜리 욕심쟁이 회의를 하나 넣었다면 그것만으로 방이 끝장났을 텐데, 종료 시각 정렬이 그런 회의를 자연스럽게 뒤로 밀어냅니다.

빅오와 두 트랙

시간 복잡도는 O(n log n)입니다. Step 1에서 말한 그대로 정렬이 지배해요. 정렬 뒤의 훑기는 회의를 한 번씩만 보니 O(n)뿐이라, 합쳐도 O(n log n)에 묻힙니다. 공간 복잡도는 O(n)이에요. sorted가 새 리스트를 만들거든요(덕분에 입력 intervals는 그대로 보존됩니다).

여기서 이 과목의 두 트랙 원칙을 짚고 갈게요. D-1에서 우리는 병합 정렬·퀵 정렬을 직접 손으로 짰습니다. 원리를 봤죠. 하지만 실전 코딩테스트에선 정렬을 직접 짜지 않고 내장 sorted를 씁니다. C로 구현돼 더 빠르고 버그가 없으니까요. 그리디 문제의 전처리는 거의 늘 정렬인데, 여기서 시간을 쓸 이유가 전혀 없습니다. 원리는 D-1에서 봤으니, 실전에선 믿고 쓰세요.

💡 한 줄 정리

회의실 배정은 종료 시각이 이른 것부터 집으면 최적이다. 짧은 회의부터·일찍 시작하는 것부터는 둘 다 반례로 무너진다. 정당성은 "먼저 끝날수록 남은 시간이 길고, 그 남은 시간이 다른 선택지의 남은 시간을 포함한다"는 한 문장이다. 끝과 시작이 같으면 안 겹치므로 조건은 start >= last_end. 시간 O(n log n)·공간 O(n).

🙋 학생 질문 — "'짧은 회의부터' 집는 게 왜 틀린가요? 짧아야 많이 들어갈 것 같은데요."

아주 자연스러운 직관이고, 그래서 많은 분이 여기에 걸립니다. 반례를 하나 만들어 보면 곧장 무너져요.

회의 세 개를 이렇게 놓아 봅시다. (0,5), (4,6), (5,10). 가운데 (4,6)이 길이 2로 가장 짧죠. "짧은 회의부터" 기준이면 이걸 먼저 집습니다. 그런데 (4,6)은 앞의 (0,5)와도 겹치고(5 > 4), 뒤의 (5,10)과도 겹칩니다(6 > 5). 결국 답은 1개예요.

반면 종료 시각으로 정렬하면 (0,5)를 먼저 집고, 다음으로 (5,10)을 집습니다(5 >= 5라 이어서 쓸 수 있죠). 답은 2개. 짧은 회의 하나가 가운데를 가로막아 양옆을 모두 죽인 겁니다.

교훈은 이거예요. 회의의 길이는 미래에 아무 영향이 없습니다. 미래를 결정하는 건 오직 언제 끝나느냐뿐이에요. 짧아도 늦게 끝나면 방을 오래 잡고 있는 거고, 길어도 일찍 끝나면 방을 빨리 비워 주는 겁니다. "무엇이 미래를 결정하는가"를 정확히 짚는 것 — 그게 그리디에서 올바른 정렬 기준을 찾는 감각입니다.


Step 4: "그리디가 깨지는 순간" (거스름돈 반례)

Step 3에서 그리디의 아름다운 면을 봤습니다. 여덟 줄로 최적을 보장했죠. 이제 반대편을 볼 차례입니다. 오늘 수업에서 가장 중요한 Step이에요. 그리디가 조용히 틀린 답을 내는 순간을 직접 목격할 겁니다.

무대는 Step 1의 그 거스름돈입니다. 코드부터 볼게요.

Python
# algorithms/greedy_divide.py
def change_making_greedy(amount, coins):
    used = []
    remaining = amount
    for coin in sorted(coins, reverse=True):    # 큰 동전부터 (입력 순서는 상관없다)
        count, remaining = divmod(remaining, coin)   # 이 동전으로 최대 몇 개 + 남은 금액
        used.extend([coin] * count)             # 집은 만큼 담는다 — 되돌아보지 않는다
    if remaining > 0:                           # 1원짜리까지 썼는데도 남았다 = 못 만듦
        return None
    return used

또 그리디의 표준 골격입니다. sorted(coins, reverse=True)로 큰 동전부터 줄 세우고(정렬), 종류마다 최대한 집습니다(훑기). divmod(remaining, coin)는 몫과 나머지를 한 번에 주는 파이썬 내장이라, "이 동전으로 몇 개 쓸 수 있나"와 "쓰고 나면 얼마 남나"를 한 줄에 구해요. 그리고 한 번 집은 동전은 절대 되돌아보지 않습니다.

한국 동전으로 1370원을 거슬러 보죠.

Python
change_making_greedy(1370, [1, 5, 10, 50, 100, 500])
# → [500, 500, 100, 100, 100, 50, 10, 10]

동전 여덟 개. 여러분이 머릿속으로 계산한 것과 똑같죠? 그리고 이게 진짜 최소입니다. 18원으로도 확인해 볼게요. 그리디는 [10, 5, 1, 1, 1]로 다섯 개를 쓰는데, 진짜 최소 개수를 구해 봐도 5개로 똑같습니다. 한국 동전 체계에선 그리디가 늘 정답이에요.

그런데 그건 그리디 덕이 아니다

여기서 오늘의 가장 중요한 질문을 던집니다. 그리디는 왜 한국 동전에서 최적일까요?

정답은 조금 서늘합니다. 그리디가 똑똑해서가 아니라, 한국 동전 체계가 잘 설계돼 있어서예요. 1·5·10·50·100·500을 보세요. 큰 동전이 작은 동전들의 배수로 맞물려 있습니다. 500은 100의 5배, 100은 50의 2배, 50은 10의 5배… 이렇게 맞물린 체계에선 "큰 걸 최대한 집는" 게 절대 손해를 안 봅니다.

그렇다면 체계를 바꾸면? 곧장 깨집니다.

텍스트
 6원을 [1, 3, 4]로 거슬러 본다

   그리디:  4를 집는다  남은 2  1을 집는다  남은 1  1을 집는다  남은 0
            결과 [4, 1, 1]  동전 3개.  한 번 집은 4를 되돌아보지 않는다.

   진짜 최적: 3을 집는다  남은 3  3을 집는다  남은 0
            결과 [3, 3]     동전 2개.

   4가 가장 커 보여서 집었을 뿐인데, 그 한 걸음이 나머지를 통째로 망쳤다.
Python
change_making_greedy(6, [1, 3, 4])
# → [4, 1, 1]        동전 3개 — 그리디의 답

min_coins_bruteforce(6, [1, 3, 4])
# → 2                동전 2개 — 진짜 최소 (3+3)

반례를 잡았습니다. 그리디는 3개, 진짜 최적은 2개. 눈앞의 최선(가장 큰 4)이 전체의 최선이 아니었어요. Step 2에서 배운 대로, 반례 하나면 그리디는 이 문제에서 폐기입니다.

두 번째 실패 모드 — 아예 답을 못 찾는다

더 심한 경우도 있습니다. 그리디는 틀린 답을 내는 데서 그치지 않고, 답이 분명히 있는데도 못 찾을 수 있어요.

텍스트
 6원을 [3, 4]로 거슬러 본다 — 답이 있는데도 그리디는 못 찾는다

   그리디:  4를 집는다  남은 2  3도 못 넣고 4도 못 넣는다  막힘  None
   진짜:    3 + 3 = 6                                             2개

   되돌아볼 수만 있었다면 "4를 무르고 3으로" 갈 수 있었다.
   그런데 그리디에 '무르기'는 없다.
Python
change_making_greedy(5, [3])
# → None             그리디: 3을 집고 2가 남아 막힘

min_coins_bruteforce(5, [3])
# → -1               완전탐색: 어떤 조합으로도 5원은 불가능

5원을 3원짜리로 거스르는 건 실제로 불가능하니 둘 다 실패로 일치합니다. 하지만 [3, 4]로 6원을 거스르는 경우엔 3+3이라는 멀쩡한 답이 있는데도 그리디만 None을 내요. 되돌아보지 않는다는 그리디의 속도가, 그대로 한계가 되는 순간입니다.

반례를 증명한 완전탐색 — E-1의 회수

그런데 잠깐. "진짜 최소는 2개"라는 걸 우리는 어떻게 알았을까요? 그리디가 틀렸다고 말하려면 진짜 최적을 알고 있어야 합니다. 그 역할을 하는 게 min_coins_bruteforce예요. E-1의 완전탐색이 여기서 심판으로 돌아옵니다.

Python
def min_coins_bruteforce(amount, coins):
    def search(remaining):
        if remaining == 0:              # 종료 조건: 딱 맞췄다 — 더 쓸 동전이 없다
            return 0
        if remaining < 0:               # 넘어섰다 — 이 가지는 가망 없다(가지치기)
            return float("inf")
        fewest = float("inf")
        for coin in coins:              # 동전 하나를 쓰는 모든 경우로 갈라진다
            fewest = min(fewest, search(remaining - coin) + 1)
        return fewest

    result = search(amount)
    return -1 if result == float("inf") else result     # 어느 가지로도 못 맞췄다

E-1의 subset_sum과 똑같은 방식이에요. 영리한 수 없이 전부 뒤집니다. 남은 금액에서 동전 하나를 뺀 모든 경우로 갈라져 내려가고, 그중 가장 적은 개수를 고르죠. 남은 금액이 음수가 되면 그 가지는 가망이 없으니 접습니다(백트래킹의 가지치기). 이 함수엔 그리디 같은 영리함이 없어서, 오히려 절대 틀리지 않습니다.

텍스트
 min_coins_bruteforce(6, [1,3,4]) — 남은 금액마다 세 갈래로 갈라진다

   remaining 6
     |
     +- 1 사용 -> 5 -+- 1 -> 4 -> ...
     |               +- 3 -> 2 -> ...
     |               +- 4 -> 1 -> ...
     |
     +- 3 사용 -> 3 -+- 1 -> 2 -> ...
     |               +- 3 -> 0     <- 여기서 동전 2개로 완성. 이게 최적이다
     |               +- 4 -> -1    (음수 — 가지치기)
     |
     +- 4 사용 -> 2 -+- 1 -> 1 -> ...
                     +- 3 -> -1    (가지치기)
                     +- 4 -> -2    (가지치기)

그리디가 한 줄기로 내려갔던 그 나무를, 완전탐색은 통째로 뒤집니다. 그래서 3+3이라는 답을 찾아내고, 그리디의 [4,1,1]이 틀렸음을 증명해요.

빅오 — 그리고 이 심판의 치명적 약점

두 함수의 복잡도를 나란히 놓으면 트레이드오프가 선명합니다.

함수 시간 복잡도 공간 복잡도 정확성
change_making_greedy O(n log n + k) O(n + k) ❌ 동전 체계에 따라 틀림
min_coins_bruteforce 지수 시간 O(amount / min(coins)) ✅ 항상 정확

그리디는 O(n log n + k)입니다. n은 동전 종류 수라 정렬이 O(n log n)이고, 종류마다 divmod로 개수를 한 번에 구하니 훑기는 O(n)뿐이에요. k는 사용한 동전 개수(최대 amount÷최소동전)로, 결과 리스트를 만드는 값입니다. 동전 종류는 보통 열 개 남짓이라 사실상 O(k)죠. 눈 깜짝할 사이입니다.

완전탐색은 지수 시간이에요. 매 호출이 동전 종류 수만큼 갈라지고 깊이가 amount÷최소동전까지 가니, 대략 O(len(coins)^(amount/min(coins)))입니다. 공간은 재귀 스택으로 O(amount / min(coins))고요. amount=6·coins=[1,3,4]는 순식간이지만, 금액이 조금만 커지면 그대로 무너집니다. A-1의 "1초에 1억 연산" 잣대로 보면 몇십만을 넘길 때부터 눈에 띄게 느려지고, 백을 넘기면 사실상 끝나지 않아요.

그래서 우리는 딱한 처지에 놓였습니다. 빠른 쪽은 틀리고, 맞는 쪽은 느리다. 6원짜리 반례를 증명하는 데는 완전탐색으로 충분했지만, 실제 문제에서 "10000원을 [1,3,4]로 최소 동전으로"를 물으면 두 함수 모두 쓸모가 없어요.

왜 이렇게 느린가 — 여기가 다음 시간의 문이다

이 완전탐색이 왜 그렇게 느린지 위 나무를 다시 보세요. 뭔가 눈에 걸리지 않나요?

같은 remaining이 여러 갈래에서 되풀이 등장합니다. remaining=3을 보세요. 3원짜리를 바로 집어서 닿을 수도 있고, 1+1+1로 닿을 수도 있고, 다른 경로로도 닿습니다. 그런데 어떤 길로 닿았든 "남은 3원을 최소 동전으로 만드는 법"은 완전히 똑같아요. 이미 한 번 계산해 봤는데도, 완전탐색은 새 갈래로 닿을 때마다 처음부터 다시 팝니다.

이쯤 되면 이런 생각이 드실 겁니다. "한 번 푼 건 어디 적어 두고 다시 쓰면 되지 않나?"

바로 그 생각이 드는 순간이 다음 시간(E-4) 동적 계획법(dynamic programming)의 문입니다. 오늘은 문 앞까지만 가고, 그 문을 여는 건 다음 시간에 해요. 오늘 여러분이 챙겨 갈 건 이겁니다. 그리디는 빠르지만 틀릴 수 있고, 완전탐색은 맞지만 느리다. 둘 사이에 세 번째 길이 있어야 한다 — 그 목마름이 다음 시간의 출발점입니다.

💡 한 줄 정리

한국 동전에서 그리디가 최적인 건 그리디 덕이 아니라 동전 체계가 배수로 맞물려 있어서다. [1,3,4]로 6원을 거슬러 보면 그리디는 3개, 진짜 최적은 2개로 곧장 깨진다. 심지어 [3,4]로 6원이면 답이 있는데도 못 찾아 None을 낸다. 진짜 최적을 알려주는 완전탐색은 항상 맞지만 지수 시간이라, 금액이 커지면 무너진다.

🙋 학생 질문 — "실패했을 때 하나는 None, 하나는 -1이던데요. 왜 반환값을 다르게 맞췄나요?"

정말 좋은 관찰이에요. 실수가 아니라 의도한 겁니다. 그리고 그 이유가 각 함수의 반환 타입에 있어요.

먼저 change_making_greedy부터. 이 함수는 동전 리스트를 돌려줍니다. 그럼 실패했을 때 뭘 주면 될까요? 빈 리스트 []가 자연스러워 보이지만, 여기에 함정이 있습니다. []는 이미 정상 답으로 쓰이고 있거든요. 0원을 거슬러 달라고 하면? 동전을 하나도 안 쓰는 게 맞으니 []가 정답입니다. 그러니 []를 실패에도 쓰면 "0원을 성공적으로 거슬렀다"와 "못 만들었다"를 구분할 방법이 사라져요. 그래서 리스트가 절대 될 수 없는 값, None을 실패 신호로 씁니다.

이제 min_coins_bruteforce. 이 함수는 개수(정수)를 돌려줍니다. 개수는 음수가 될 수 없죠. 그러니 음수 아무거나 실패 신호로 쓸 수 있는데, 하필 -1인 이유는 지난 시간의 약속을 따랐기 때문이에요. E-2의 maze_shortest가 도착에 못 닿았을 때 -1을 돌려줬던 것, 기억나시죠? 같은 과목 안에서 "정수를 돌려주는데 실패했다"는 신호는 -1로 통일해 두면 읽는 사람이 헷갈리지 않습니다.

정리하면 이렇습니다. 반환 타입이 실패 신호를 정한다. 리스트를 주는 함수는 None, 정수를 주는 함수는 -1. 핵심 원칙은 "정상 답이 될 수 있는 값을 실패 신호로 쓰지 말 것"이에요. 이건 알고리즘 이론이 아니라 코드를 안전하게 짜는 감각인데, 코딩테스트에서도 은근히 발목을 잡습니다. "빈 결과인가, 실패인가"를 구분 못 해 틀리는 경우가 정말 많거든요.


Step 5: "쪼갠다, 정복한다" (분할정복 개념)

여기까지가 그리디였습니다. 매 순간 눈앞의 최선을 집는 전략이었죠. 이제 오늘의 두 번째 사고법으로 넘어갑니다. 분할정복(divide and conquer) — 문제를 반으로 쪼개 각각 풀고 합치는 방식이에요.

그런데 여러분, 사실 이건 처음 만나는 게 아닙니다. D-1에서 이미 만났어요. 그때는 "정렬 알고리즘"이라는 이름표를 달고 있어서 못 알아봤을 뿐이죠. 오늘 다시 보면 그게 분할정복 그 자체였다는 게 드러납니다. 반가운 재회를 해 봅시다.

분할 → 정복 → 병합

분할정복은 세 걸음으로 정의됩니다.

  • 분할(divide) — 문제를 같은 모양의 작은 문제로 쪼갠다
  • 정복(conquer) — 쪼갠 조각을 각각 푼다. 더 못 쪼갤 만큼 작아지면 그냥 답이 나온다
  • 병합(combine) — 부분 답들을 합쳐 전체 답으로 만든다

말로만 들으면 추상적이니 코드로 봅시다. D-1의 병합 정렬입니다.

Python
# algorithms/sorting.py
def merge_sort(arr):
    a = list(arr)
    if len(a) <= 1:                         # 원소 0~1개는 이미 정렬된 상태 (종료 조건)
        return a
    mid = len(a) // 2
    left = merge_sort(a[:mid])              # 왼쪽 절반을 정렬
    right = merge_sort(a[mid:])             # 오른쪽 절반을 정렬
    return merge(left, right)               # 정렬된 두 조각을 하나로 합친다

일곱 줄짜리 이 함수가 방금 정의한 세 걸음과 한 줄씩 정확히 맞물립니다.

텍스트
 분할정복 3단계 — merge_sort의 뼈대와 1:1로 맞물린다

   ① 분할(divide)    a[:mid]  /  a[mid:]
                      반으로 가른다. 절반짜리도 여전히 '정렬하는 문제'다

   ② 정복(conquer)   merge_sort(a[:mid])  ·  merge_sort(a[mid:])
                      각 절반을 같은 방법으로 푼다 — 자기를 다시 부른다
                     if len(a) <= 1: return a
                      원소 1개는 이미 정렬 완료 — 여기가 바닥(종료 조건)

   ③ 병합(combine)   merge(left, right)
                      정렬된 두 조각을 정렬된 하나로 합친다

mid로 반을 가르는 게 분할, 그 절반을 각각 merge_sort에 다시 넘기는 게 정복, merge로 합치는 게 병합이에요. D-1에서 "병합 정렬은 반으로 쪼개 합치는 정렬"이라고 배웠던 그 문장이, 알고 보니 분할정복의 정의를 그대로 읽은 것이었습니다.

바닥까지 갔다가 되돌아 나온다 — E-1의 재귀

분할정복은 혼자 서지 못합니다. E-1에서 배운 재귀 호출 스택 위에 섭니다. 자기를 다시 부르며 조각이 점점 작아지다가, 바닥(종료 조건)에 닿으면 그때부터 답이 합쳐지며 되돌아 나와요. 네 개짜리 리스트로 그 과정을 눈으로 따라가 봅시다.

텍스트
 merge_sort([38, 27, 43, 3]) — 바닥까지 쪼갰다가 정렬되며 올라온다

   깊이 0          [38, 27, 43, 3]
                     /         \              분할 — 반으로 가른다
   깊이 1     [38, 27]          [43, 3]
               /   \             /   \
   깊이 2    [38]  [27]        [43]  [3]     더 못 쪼갠다 = 바닥(종료 조건)
               \   /             \   /
   깊이 1     [27, 38]          [3, 43]       병합 — 정렬된 둘을 합친다
                     \         /
   깊이 0          [3, 27, 38, 43]           정렬 완료

   원소 4개  쪼개는 깊이는 2 = log₂4.  원소가 1024개여도 깊이는 10걸음이다.

내려갈 때는 아무 일도 안 합니다. 그냥 쪼개기만 해요. 진짜 일은 올라오면서 벌어집니다. [38][27]이 만나 [27, 38]이 되고, [3, 43]과 만나 [3, 27, 38, 43]이 되죠. 재귀가 바닥을 찍고 되돌아 나오는 그 길이 곧 정답이 완성되는 길입니다.

여기서 눈여겨볼 게 깊이예요. 원소 4개를 반씩 쪼개니 2번 만에 바닥에 닿았습니다. 8개면 3번, 1024개면 10번. 반씩 줄이면 깊이는 log n입니다. 이 로그가 분할정복의 심장이에요.

아무 문제나 쪼갤 수 있는 건 아니다

분할정복이 통하려면 조건이 둘 있습니다. 이게 안 맞으면 쪼개 봐야 헛수고예요.

  • 쪼갠 부분 문제가 원래와 같은 모양이어야 한다. 정렬 문제를 반으로 가르면 "절반을 정렬하는 문제"가 나오죠. 판박이입니다. 그러니 같은 함수를 그대로 다시 부를 수 있어요.
  • 부분 답을 합칠 수 있어야 한다. 왼쪽 절반이 정렬되고 오른쪽 절반이 정렬됐을 때, 이 둘을 합쳐 전체 정렬을 만들 방법(merge)이 있어야 합니다. 합칠 방법이 없으면 아무리 잘 쪼개도 답이 안 나와요.

이 둘을 확인하는 게 분할정복 문제를 알아보는 감각입니다. "반으로 갈라도 같은 문제인가? 갈라서 푼 걸 합칠 수 있나?" 두 질문에 다 예스면 분할정복이 통합니다.

빅오는 이렇게 감이 잡히죠. 깊이가 log n이고 각 깊이에서 O(n)씩 일하니 곱해서 O(n log n). 이게 D-1에서 외웠던 그 숫자예요. 다만 지금은 그림으로 대충 짐작한 수준이라, 정확한 유도는 Step 7에서 제대로 하겠습니다. 그 전에 분할정복이 가장 화려하게 빛나는 예제를 하나 보고 갈게요.

💡 한 줄 정리

분할정복은 분할(divide) → 정복(conquer) → 병합(combine) 세 걸음이다. D-1의 병합 정렬이 바로 이 사고였다 — a[:mid]/a[mid:]가 분할, 재귀 호출이 정복, merge가 병합, len(a) <= 1이 바닥이다. E-1의 재귀 스택 위에서 바닥까지 갔다 되돌아 나오며 답이 합쳐진다. 반씩 쪼개니 깊이가 log n이고, 이 로그가 분할정복의 심장이다.

🙋 학생 질문 — "재귀(E-1)랑 분할정복이랑 뭐가 다른가요? 둘 다 자기를 부르는데요."

정확히 짚으셨어요. 겉모습이 똑같아 보이니 헷갈릴 만합니다. 차이는 층위에 있습니다.

재귀는 도구예요. "함수가 자기 자신을 부른다"는 문법적 기법일 뿐입니다. 반면 분할정복은 전략입니다. "문제를 반으로 쪼개 각각 풀고 합친다"는 문제 해결 방침이에요. 망치와 집짓기의 차이라고 보시면 됩니다. 망치는 도구고, 집짓기는 그 도구로 하는 일이죠.

그래서 관계가 한쪽으로만 성립합니다. 모든 분할정복은 재귀지만, 모든 재귀가 분할정복은 아닙니다. E-1의 팩토리얼을 떠올려 보세요. factorial(n) = n * factorial(n-1). 자기를 부르니 재귀는 맞습니다. 그런데 이게 문제를 반으로 쪼갠 건가요? 아니에요. n을 n-1로 한 칸 줄인 것뿐입니다. 쪼갠 게 아니라 깎은 거죠. 그래서 깊이가 log n이 아니라 n이고, 빅오도 O(n)입니다.

여기가 핵심입니다. 재귀라고 다 빨라지는 게 아니에요. 반으로 쪼갤 때만 깊이가 log n으로 접히고, 그때 비로소 분할정복의 이득이 납니다. 한 칸씩 깎는 재귀는 그냥 반복문을 재귀로 쓴 것과 다를 게 없어요. "이 재귀가 문제를 반으로 쪼개고 있나?"를 물어보면 분할정복인지 아닌지 곧장 구분됩니다.


Step 6: "거듭제곱을 O(log n)에" (분할정복 대표)

문제를 하나 드릴게요. 2의 1000제곱을 구하세요.

가장 먼저 떠오르는 건 이거죠. 2를 1000번 곱한다. 반복문 한 줄이면 됩니다. 틀린 답도 아니고 잘 돌아가요. 그런데 지수가 1000이 아니라 10억이라면? 곱셈을 10억 번 해야 합니다. A-1의 "1초에 1억 연산" 잣대로 보면 10초. 시간 초과죠. 지수를 그대로 세는 방식은 O(exp) — 지수 크기에 정비례해서 느려집니다.

여기서 초등학교 때 배운 지수 법칙 하나가 판을 뒤집습니다.

절반만 구하고 제곱한다

2¹⁰ = (2⁵)² 입니다. 당연한 얘기죠? 그런데 이 당연한 걸 알고리즘으로 뒤집어 읽으면 이렇게 됩니다. 2의 10제곱을 구하려고 2를 열 번 곱할 필요가 없다. 2의 5제곱만 구해서 제곱하면 된다. 곱셈이 열 번에서 여섯 번(5제곱 구하기 + 제곱 한 번)으로 줄었어요.

그럼 그 2⁵은 어떻게 구할까요? 똑같이 하면 되죠. 2²만 구해서 제곱하고… 이렇게 매번 절반만 구하면 지수가 호출할 때마다 반으로 접힙니다. Step 5에서 본 그 로그가 여기서 다시 나와요.

텍스트
 2^1000 — 지수가 반으로 접힌다

   1000  500  250  125  62  31  15  7  3  1  0
    (1)   (2)   (3)   (4)   (5)  (6)  (7)  (8) (9) (10) (11)

   열한 걸음이면 바닥(exp=0)에 닿는다.
   곱셈 1000번이 호출 11번으로 접혔다. 지수가 10억이어도 서른 걸음 남짓이다.

홀수는 어떻게 하나

그런데 위 자취에 125, 31, 15, 7, 3, 1처럼 홀수가 섞여 있습니다. 홀수는 반으로 딱 안 나뉘죠. 2⁷을 (2³)²으로 하면 2⁶이 되어 하나가 모자랍니다.

간단합니다. 모자란 하나를 손으로 채우면 돼요. exp가 홀수면 exp = 2 × (exp // 2) + 1이니, 절반을 제곱한 다음 base를 한 번 더 곱해 주면 딱 맞습니다. 2⁷ = (2³)² × 2. 이제 코드를 봅시다.

Python
# algorithms/greedy_divide.py
def power(base, exp, trace=None):
    if trace is not None:
        trace.append(exp)               # 지금 호출의 지수를 기록(반씩 접히는 흔적)
    if exp == 0:                        # 종료 조건: 무엇이든 0제곱은 1
        return 1
    half = power(base, exp // 2, trace)  # 절반만 구한다 — 반드시 한 번만 부른다
    if exp % 2 == 0:
        return half * half              # 짝수: (base^(exp/2))²
    return half * half * base           # 홀수: 반으로 안 나뉜 한 개를 더 곱해 채운다

Step 5의 세 걸음이 그대로 보입니다. exp // 2로 지수를 반 가르는 게 분할, power(base, exp // 2, trace)로 자기를 부르는 게 정복, half * half로 합치는 게 병합이에요. exp == 0이면 1을 돌려주는 게 바닥이고요. trace는 지수가 접히는 흔적을 눈으로 보려고 달아 둔 것뿐이라 계산에는 아무 영향이 없습니다.

⚠️ 한 글자 차이로 이득이 통째로 날아간다

여기가 오늘 Step에서 가장 중요한 대목입니다. 절대 놓치지 마세요.

⚠️ power(base, exp // 2)는 반드시 한 번만 부르고 그 값을 제곱해야 합니다. half라는 변수에 받아 두는 게 멋 부린 게 아니라 알고리즘의 생사가 걸린 부분이에요.

half * half 대신 power(base, exp//2) * power(base, exp//2)라고 쓰면 어떻게 될까요? 눈으로 보기엔 똑같은 값이 나옵니다. 답도 맞아요. 그런데 속도가 통째로 무너집니다.

텍스트
 왜 무너지는가 — 호출이 두 배씩 갈라진다

   half를 한 번만 구할 때            power(...) * power(...)로 쓸 때

        exp                               exp
         |                                / \
       exp/2        한 줄기            exp/2  exp/2      두 갈래로 갈라진다
         |                              /\      /\
       exp/4                        exp/4 ...  ... exp/4
         |                            ...
        ...                          잎이 2^(log exp) = exp개

   호출 log exp개    O(log exp)     호출 exp개    O(exp)   도로 원점

같은 값을 두 번 계산하니 호출이 매 단계 두 배로 갈라집니다. 깊이는 여전히 log exp인데, 가지가 2배씩 늘어나니 잎이 2^(log exp) = exp개가 돼요. 애써 얻은 O(log exp)가 도로 O(exp)로 돌아갑니다. 분할정복을 했는데 순진하게 1000번 곱한 것과 똑같아지는 거죠. Step 1의 완전탐색 나무가 여기서 유령처럼 되살아나는 셈입니다.

한 글자 차이로 로그가 선형이 됩니다. 코딩테스트에서 "분명 O(log n)으로 짰는데 시간 초과"가 뜬다면 십중팔구 여기예요.

1000번이 11번으로

제대로 짠 power를 실제로 돌려 봅시다. trace에 빈 리스트를 넘기면 호출마다 그때의 지수를 적어 주니, 접히는 과정을 눈으로 볼 수 있어요.

텍스트
2^1000 == 내장:   True
  지수가 접힌 자취: [1000, 500, 250, 125, 62, 31, 15, 7, 3, 1, 0]
  곱셈 1000번이 아니라 호출 11번으로 끝난다

첫 줄이 True라는 건 파이썬 내장 2 ** 1000과 값이 정확히 같다는 뜻입니다. 302자리짜리 어마어마한 수를 정확히 맞혔어요. 그런데 그걸 호출 열한 번에 끝냈습니다.

1000번이 11번으로. 이게 O(log n)의 실체입니다. 시간 복잡도는 O(log exp), 공간 복잡도도 O(log exp)예요(재귀 호출 스택 깊이만큼 쌓이니까요). A-1의 잣대로 감을 잡아 보면, 지수가 10억이어도 30걸음, 1조여도 40걸음입니다. 사실상 공짜죠.

두 트랙 — 실전에선 직접 안 짠다

자, 이 과목의 두 트랙 원칙을 여기서도 짚습니다. 실전 코딩테스트에서 여러분은 power를 직접 짜지 않습니다. 파이썬 내장 pow(base, exp)base ** exp를 쓰면 돼요. 왜냐면 파이썬 내장이 이미 이 분할정복(제곱 반복법)으로 돌기 때문입니다. 빅오가 O(log exp)로 똑같고, C로 구현돼 있어 더 빠르기까지 해요. 직접 짤 이유가 없죠.

그럼 오늘 이걸 왜 손으로 짰을까요? 값어치가 두 군데 있습니다.

  • pow(base, exp, mod)의 원리를 이해하기 위해서. 파이썬 pow에 세 번째 인자를 주면 "큰 수를 나눈 나머지"까지 함께 구해 줍니다. 이게 어떻게 가능한지는 오늘의 분할정복 구조를 알아야 보여요(아래 토글에서 이어집니다).
  • 면접에서 설명하기 위해서. "거듭제곱을 O(log n)에 구하는 법을 아세요?"는 단골 질문입니다. "지수를 반씩 접고, 절반을 한 번만 계산해 제곱합니다" — 이 한 문장이 나오면 끝나요.

원리를 아는 사람이 라이브러리를 믿고 씁니다. 원리를 모르면 언제 믿어도 되는지조차 판단하지 못해요.

💡 한 줄 정리

거듭제곱은 2¹⁰ = (2⁵)² 이라는 지수 법칙 하나로 O(exp)에서 O(log exp)로 내려앉는다. 지수를 반씩 접어 내려가고, 홀수면 base를 한 번 더 곱해 채운다. 2^1000이 곱셈 1000번이 아니라 호출 11번으로 끝난다. 단 half를 한 번만 구해 제곱해야 하고, power(...) * power(...)로 쓰면 호출이 exp개로 불어나 이득이 통째로 날아간다. 실전에선 같은 원리로 도는 내장 pow를 쓴다.

🙋 학생 질문 — "2^1000이 파이썬에서 되는 게 신기해요. 오버플로가 안 나나요?"

날카로운 질문이에요. 다른 언어를 먼저 배우셨다면 당연히 걸릴 만한 부분입니다.

파이썬의 int임의 정밀도(arbitrary precision) 정수예요. 크기 제한이 아예 없습니다. 메모리가 허락하는 한 무한히 커져요. 그래서 302자리짜리 2^1000도 아무렇지 않게 정확히 담아냅니다. 파이썬이 코딩테스트에서 사랑받는 이유 중 하나가 이거예요. 큰 수 문제에서 오버플로를 신경 쓸 일이 없거든요.

C나 자바라면 얘기가 완전히 다릅니다. 자바의 long은 64비트라 대략 9.2×10¹⁸까지밖에 못 담아요. 2^1000은커녕 2^63에서 이미 넘칩니다. 넘치면 조용히 이상한 값으로 뒤집혀요(오버플로). 그래서 자바로 이런 문제를 풀면 BigInteger를 꺼내거나, 아예 다른 길로 갑니다.

그 "다른 길"이 바로 코딩테스트의 그 문구예요. "답을 10^9+7로 나눈 나머지를 출력하시오." 이 문구를 보신 적 있죠? 왜 하필 나머지를 물을까요? 답이 너무 커서 어떤 자료형에도 안 들어가니, 나머지만 물어 크기를 잡아 두는 것입니다. 출제자의 배려이자 방어죠.

그럼 나머지는 어떻게 구할까요? 답을 다 구한 다음 나누면 될까요? 안 됩니다. 다 구하는 순간 이미 넘쳤으니까요. 대신 곱할 때마다 나머지를 취하면 됩니다. (a × b) % m = ((a % m) × (b % m)) % m이라는 성질 덕분에, 중간중간 나머지를 취해도 최종 답이 똑같거든요. 오늘의 power에서 half * halfhalf * half % mod로 바꾸기만 하면 되는 겁니다. 파이썬은 이걸 pow(base, exp, mod)로 이미 만들어 뒀고요.

정리하면, 파이썬 쓰는 분은 오버플로 걱정은 없지만 "10^9+7로 나눈 나머지"를 만나면 pow(base, exp, mod)를 떠올리시면 됩니다. 그리고 그게 왜 O(log exp)로 도는지는, 방금 손으로 짜 봤으니 이제 아시죠.


Step 7: "왜 O(n log n)인가" (정렬의 분할정복 재분석)

D-1에서 제가 이렇게 말씀드렸습니다. "병합 정렬은 O(n log n)입니다." 여러분은 그걸 외웠어요. 왜 그런지는 그때 깊이 파지 않았죠. 아직 분할정복이라는 도구가 없었으니까요.

이제 도구가 생겼습니다. 오늘 그 O(n log n)을 직접 유도해 봅니다. 외운 걸 이해로 바꾸는 시간이에요. 그리고 이 유도가 끝나면 덤으로 이런 것까지 풀립니다. "같은 분할정복인데 퀵 정렬은 왜 최악 O(n²)이지?"

깊이 × 각 깊이에서 하는 일

분할정복의 비용 계산은 놀랍도록 단순한 곱셈입니다.

전체 비용 = 쪼개는 깊이 × 각 깊이에서 하는 일

두 항을 따로 구해 보죠. 먼저 깊이입니다. n을 반으로, 또 반으로 쪼개서 1이 될 때까지 몇 번 걸릴까요? 이건 Step 6에서 지수를 반씩 접었던 것과 완전히 같은 논리예요. 반씩 줄이면 log₂n 번입니다. 원소 1024개면 10번, 100만 개여도 20번.

다음은 각 깊이에서 하는 일입니다. 여기가 살짝 헷갈리는 부분이에요. 깊이가 내려갈수록 조각이 잘게 부서지니 일이 줄어들 것 같죠? 그런데 조각 수가 그만큼 늘어납니다. 그림으로 보면 확실해요.

텍스트
 병합 정렬의 총 비용 = 깊이 × 각 깊이에서 하는 일

   깊이 0      [             n             ]     merge가 훑는 원소 = n개
   깊이 1      [     n/2     ][     n/2    ]     n/2 + n/2 = n개
   깊이 2      [  n/4 ][ n/4 ][ n/4 ][ n/4 ]     n/4 × 4 = n개
     :                    ...                    어느 깊이를 잘라도 합치면 n개
   깊이 log n  [1][1][1][1][1][1][1] ... [1]     1 × n = n개

   각 깊이에서 O(n)  ×  깊이 log₂n  =  O(n log n)

보이시나요? 어느 깊이를 가로로 잘라 봐도 그 줄의 원소를 다 합치면 n개입니다. 조각이 반으로 작아지면 조각 수가 두 배로 늘어나니, 곱하면 언제나 n이에요. 그리고 merge는 그 깊이의 원소들을 딱 한 번씩 훑어 합치니 각 깊이의 일이 O(n)입니다.

이제 곱하면 됩니다. O(n) × log₂n = O(n log n). 끝났어요. D-1에서 외웠던 그 숫자가, 오늘 두 줄 곱셈으로 유도됐습니다.

여기서 병합 정렬의 자랑 하나. 이 유도 어디에도 "운이 좋으면"이라는 말이 없었죠? mid = len(a) // 2는 입력이 뭐든 무조건 반으로 가릅니다. 그러니 깊이가 log n이라는 게 보장돼요. 병합 정렬이 최악에도 O(n log n)으로 흔들리지 않는 이유가 이겁니다. 대신 merge가 임시 리스트를 만드니 공간 O(n)을 씁니다.

그런데 퀵 정렬은 왜 최악 O(n²)인가

퀵 정렬도 분할정복입니다. 그런데 D-1에서 배웠듯 최악이 O(n²)이에요. 같은 사고법인데 왜 이런 차이가 날까요? 코드를 다시 꺼내 봅시다.

Python
# algorithms/sorting.py
def quick_sort(arr):
    a = list(arr)
    _quick_sort(a, 0, len(a) - 1)
    return a


def _quick_sort(a, lo, hi):
    if lo < hi:
        p = partition(a, lo, hi)           # 피벗을 제자리에 놓고 그 위치를 받는다
        _quick_sort(a, lo, p - 1)          # 피벗 왼쪽(작은 값들)
        _quick_sort(a, p + 1, hi)          # 피벗 오른쪽(큰 값들)


def partition(a, lo, hi):
    pivot = a[hi]
    i = lo - 1
    for j in range(lo, hi):
        if a[j] <= pivot:
            i += 1
            a[i], a[j] = a[j], a[i]
    a[i + 1], a[hi] = a[hi], a[i + 1]       # 피벗을 작은/큰 무리의 경계로 옮긴다
    return i + 1

_quick_sort도 자기를 두 번 부르며 좌우로 갈라집니다. 분할정복 맞아요. 그런데 결정적인 차이가 하나 있습니다.

병합 정렬은 mid = len(a) // 2내가 직접 반을 갈랐습니다. 반면 퀵 정렬은 partition이 돌려준 p에서 갈라져요. 그리고 이 p가 어디 놓일지는 피벗 값과 입력 데이터가 정합니다. 내가 정하는 게 아니에요.

partition을 보시면 pivot = a[hi] — 구간의 맨 끝 원소를 피벗으로 씁니다. 운이 좋아 피벗이 중간값 근처면 좌우가 반반으로 갈라져요. 그런데 피벗이 하필 그 구간에서 가장 큰 값이라면? 모든 값이 피벗보다 작으니 전부 왼쪽으로 몰리고, 오른쪽엔 아무것도 안 남습니다. 1 대 (n-1)로 갈라지는 거죠.

이미 정렬된 입력이 정확히 이 경우예요. 맨 끝 원소가 늘 최댓값이니까요. "정렬된 걸 정렬하면 가장 느리다"는 유명한 역설이 여기서 나옵니다.

텍스트
 같은 분할정복인데 — 쪼개지는 균형이 갈린다

 ① 균형 분할 (피벗이 중간값 근처)
         n
        / \
     n/2   n/2           매번 반씩  바닥까지 log n 걸음
      /\    /\
    n/4 n/4 n/4 n/4
        ...
    1 1 1 1 1 1 1 1      깊이 log n × 각 깊이 O(n)  =  O(n log n)

 ② 치우친 분할 (피벗이 매번 한쪽 끝값 — 이미 정렬된 입력 등)
         n
        / \
       1   n-1           한 개만 떨어져 나가고 나머지가 통째로 남는다
          / \
         1   n-2
            / \
           1   n-3
              ...        깊이가 log n이 아니라 n
           1   1         깊이 n × 각 깊이 O(n)  =  O(n²)

②를 보세요. 한 번 쪼갤 때마다 원소가 딱 하나씩만 떨어져 나갑니다. 그러니 바닥에 닿기까지 log n이 아니라 n번이 걸려요. 각 깊이에서 partition이 구간을 훑는 O(n)은 그대로인데 깊이만 n으로 늘어났으니, n × O(n) = O(n²). 분할정복의 이득이 사라지고 그냥 이중 반복문이 된 겁니다.

교훈 — 제대로 쪼개야 이득이 난다

여기서 오늘의 두 번째 사고법을 관통하는 교훈이 나옵니다.

분할정복의 이득은 "반으로 쪼개진다"는 보장에서 옵니다. log n이라는 마법의 출처가 거기예요. 그 보장이 깨지는 순간, 이득도 함께 사라집니다.

그리고 이건 Step 6에서 본 함정과 정확히 같은 이야기입니다. power(...) * power(...)로 쓰면 호출이 두 배씩 갈라져 O(log exp)가 O(exp)로 무너졌죠. 퀵 정렬은 분할이 치우쳐 O(n log n)이 O(n²)로 무너집니다. 증상은 달라 보이지만 원인은 하나예요. "제대로 쪼개지 않으면 분할정복이 아니다." 껍데기만 분할정복이면 빅오는 정직하게 무너집니다.

그렇다면 퀵 정렬은 못 쓰는 알고리즘일까요? 전혀 아닙니다. 트레이드오프를 표로 놓고 보죠.

항목 병합 정렬 퀵 정렬
평균 시간 O(n log n) O(n log n)
최악 시간 🌟 O(n log n) 보장 ❌ O(n²) (분할이 치우칠 때)
공간 O(n) (임시 리스트) 🌟 O(log n) (재귀 스택뿐)
안정성 🌟 안정(같은 값의 순서 보존) ❌ 불안정
실전 속도 무난 🌟 상수가 작아 대체로 더 빠름

퀵 정렬은 임시 배열 없이 배열 안에서 원소끼리 위치를 맞바꿔 가며 정렬하니 공간이 O(log n)뿐이고, 상수 계수가 작아 평균적으로는 병합 정렬보다 빠릅니다. 그래서 실제 라이브러리들이 오래 애용했어요. 다만 최악이 O(n²)이라, 최악을 절대 허용할 수 없는 곳에선 병합 정렬을 씁니다.

"보장이냐 평균 속도냐" — 이게 두 정렬의 트레이드오프입니다. 둘 다 분할정복인데도 이렇게 갈리는 이유가, 방금 본 "분할의 균형"이었어요.

💡 한 줄 정리

분할정복의 비용은 깊이 × 각 깊이의 일이다. 병합 정렬은 반으로 쪼개니 깊이 log₂n, 각 깊이에서 merge가 원소 n개를 훑으니 O(n) — 곱해서 O(n log n)이고, mid로 직접 반을 가르니 최악에도 보장된다. 퀵 정렬은 피벗이 분할을 정해, 피벗이 매번 끝값이면(이미 정렬된 입력) 깊이가 n이 되어 O(n²)로 무너진다. 이득은 균형 있게 쪼개진다는 보장에서 온다.

🙋 학생 질문 — "그럼 실전에선 병합 정렬을 써야 하나요, 퀵을 써야 하나요?"

실전 답은 조금 김빠지실 텐데요. 둘 다 직접 안 짭니다. sorted를 쓰세요.

파이썬의 sortedlist.sortTimsort라는 알고리즘으로 돕니다. 이름이 재밌죠? 만든 사람 이름이 Tim이라 Timsort예요. 이게 뭐냐면 병합 정렬과 삽입 정렬을 섞은 하이브리드입니다.

핵심 아이디어가 아름다워요. 현실의 데이터는 완전히 무작위인 경우가 드뭅니다. 어딘가는 이미 정렬돼 있죠(로그가 시간순으로 쌓였다든가, 절반쯤 정렬된 상태라든가). Timsort는 그런 이미 정렬된 구간(run)을 먼저 찾아내 그대로 살려 두고, 그 구간들을 병합 정렬처럼 합칩니다. 짧은 조각은 그 크기에서 더 빠른 삽입 정렬로 처리하고요. 그래서 이미 정렬된 입력을 만나면 O(n)에 끝나기도 합니다. 퀵 정렬이 이미 정렬된 입력에서 최악이 되는 것과 정반대죠.

거기다 안정 정렬이라 같은 값의 원래 순서가 보존되고(다중 기준 정렬에서 결정적입니다), 최악도 O(n log n)으로 보장되며, C로 구현돼 있어 손으로 짠 파이썬 코드와는 비교가 안 되게 빠릅니다. 병합 정렬의 보장과 퀵 정렬의 실전 속도를 둘 다 챙긴 셈이에요. 직접 짤 이유가 정말 없습니다.

그럼 D-1에서 왜 손으로 짰냐고요? 두 가지 때문입니다. 첫째, 원리를 알아야 판단합니다. "이 데이터는 이미 거의 정렬돼 있는데 괜찮을까?" 같은 질문에 답하려면 안이 어떻게 도는지 알아야 해요. 둘째, 면접에서 물어봅니다. "병합 정렬과 퀵 정렬의 차이를 설명해 보세요"는 단골 중의 단골이고, 오늘 배운 "분할의 균형이 갈린다"는 그 답의 핵심입니다.

실전 규칙은 한 줄이에요. 정렬이 필요하면 sorted, 원리를 묻거든 오늘 배운 걸로 답한다.


Step 8: "그리디냐 분할정복이냐" (유형 판별 정리)

오늘 두 사고법을 다 익혔으니, 마지막으로 "어떤 문제에 무엇을 꺼낼까"를 정리합시다. 코딩테스트에서 문제를 만났을 때 가장 먼저 던질 질문이 바로 이거예요.

두 사고법은 애초에 겨루는 관계가 아닙니다. 쓰이는 데가 다르거든요. 그리디는 여러 후보 중에서 고르는 문제에, 분할정복은 문제를 쪼갤 수 있는 문제에 통합니다. 신호로 정리하면 이렇습니다.

문제에서 보이는 신호 꺼낼 사고법 반드시 확인할 것
"최대 몇 개를 고를 수 있나"·"최소 몇 개면 되나" 그리디 이 기준으로 집어도 최적인가 (교환 논법 / 반례)
정렬해 놓고 앞에서부터 집으면 될 것 같다 그리디 무엇이 미래를 결정하는가 (정렬 기준)
반으로 쪼개도 같은 문제가 나온다 분할정복 부분 답을 합칠 방법이 있나
지수·탐색 범위처럼 반씩 접을 대상이 있다 분할정복 균형 있게 쪼개지는 게 보장되나

그리디는 반례 하나면 곧장 폐기입니다. 그러니 코드를 짜기 전에 작은 입력을 몇 개 던져 보세요(Step 2). 안 깨지면 "왜 손해를 안 보지?"를 한 문장으로 말해 보고, 그 문장이 나오면 자신 있게 짜면 됩니다.

분할정복은 두 질문으로 판정합니다. "반으로 갈라도 같은 문제인가? 갈라서 푼 걸 합칠 수 있나?" 둘 다 예스면 통해요. 그리고 짤 때는 Step 6·7의 교훈 — 제대로, 균형 있게 쪼개졌는지 — 를 확인하시고요.

그리디 vs 동적 계획법 — 한 줄만

여기서 다음 시간을 향한 문 하나를 열어 두겠습니다. 딱 한 줄만요.

그리디는 한 번 고르면 되돌아보지 않고, 동적 계획법(DP)은 모든 선택지를 따져 보되 겹치는 계산을 기억해 재활용합니다.

Step 4의 거스름돈이 정확히 그 경계였어요. 동전 체계가 받쳐 주면(한국 동전) 그리디로 충분하고, 안 받쳐 주면([1,3,4]) 그리디가 틀린 답을 냅니다. 그럼 [1,3,4]는 어떻게 풀어야 할까요? 그게 다음 시간의 주제입니다. 오늘은 이 한 줄만 남기고 넘길게요.

분할정복 vs 동적 계획법 — 이것도 한 줄

한 줄 더 있습니다. 분할정복과 DP도 얼핏 닮았거든요. 둘 다 문제를 쪼개니까요.

차이는 쪼갠 조각이 겹치느냐입니다.

병합 정렬의 왼쪽 절반과 오른쪽 절반을 떠올려 보세요. 서로 전혀 안 겹칩니다. 왼쪽을 정렬한 결과가 오른쪽 정렬에 쓰일 일이 없어요. 그러니 기억해 둘 값어치도 없습니다. 반면 Step 4의 거스름돈 나무에서는 remaining=3이 여러 갈래에서 되풀이 등장했죠. 겹치는 겁니다. 겹치면 비로소 "한 번 푼 걸 적어 두고 다시 쓸" 값어치가 생겨요.

조각이 안 겹치면 분할정복, 겹치면 그때부터 다음 시간의 이야기입니다.

코테에선 이렇게 나온다

오늘 배운 두 사고법이 코딩테스트에서 어떤 이름표를 달고 나오는지 짝지어 둘게요.

사고법 코테 빈출 유형
그리디 회의실 배정·강의실 배정, 거스름돈, 큰 수의 법칙, 잃어버린 괄호, 동전 0
분할정복 거듭제곱(A의 B제곱), 병합 정렬·퀵 정렬, 색종이 만들기, 종이의 개수

오늘 짠 네 함수 — greedy_interval_scheduling, change_making_greedy, min_coins_bruteforce, power — 가 이 유형들의 뼈대입니다. 코드베이스 algorithms/greedy_divide.py에 전부 들어 있으니, 문제를 만나면 이 골격을 꺼내 변형해 쓰세요.

💡 한 줄 정리

고르는 문제면 그리디, 쪼갤 수 있는 문제면 분할정복이다. 그리디는 "이 기준으로 집어도 최적인가"를 반례로 먼저 때려 보고, 분할정복은 "반으로 갈라도 같은 문제인가·합칠 수 있나"로 판정한다. 그리디는 되돌아보지 않고 DP는 겹치는 계산을 기억한다는 것, 분할정복은 조각이 안 겹치고 DP는 겹친다는 것 — 이 두 줄이 다음 시간으로 가는 문이다.

🙋 학생 질문 — "문제만 보고 어떤 유형인지 바로 알 수 있나요? 저는 아직 감이 전혀 없는데요."

처음엔 아무도 못 알아봅니다. 저도 그랬어요. 그리고 솔직히 말씀드리면, 숙련자도 "바로" 아는 게 아니라 후보를 몇 개 떠올린 뒤 빠르게 걸러 내는 것에 가깝습니다. 그 걸러 내는 속도가 경험이에요.

다만 도움이 되는 습관이 있습니다. 문제를 읽으면서 이 순서로 물어보세요.

첫째, 입력 크기 n을 먼저 봅니다. 이게 놀랍도록 강력한 힌트예요. n이 100만이면 O(n log n) 이하만 허용되니 완전탐색·DP는 애초에 후보에서 빠지고 그리디나 정렬 계열이 남습니다. n이 20 남짓이면 대놓고 "완전탐색 하세요"라는 뜻이고요. A-1의 "1초에 1억 연산" 잣대가 여기서 문제 유형을 좁혀 줍니다.

둘째, 묻는 게 무엇인지 봅니다. "최대 몇 개"·"최소 몇 개"면 고르는 문제고, 그리디나 DP가 후보예요. "몇 가지 경우의 수"면 완전탐색이나 DP, "최단 거리"면 지난 시간의 BFS죠.

셋째, 그리디 후보라면 반례부터 던집니다. 이게 오늘 배운 것 중 실전에서 가장 값집니다. 원소 2~3개짜리 작은 입력으로 손계산해 보세요. 깨지면 그리디는 폐기고, 30초를 아낀 겁니다.

그리고 이 말씀을 꼭 드리고 싶어요. 유형을 못 알아보는 건 실력이 없어서가 아니라 아직 본 문제가 적어서입니다. 감각은 문제 수에 정직하게 비례해요. 같은 유형을 다섯 문제쯤 풀면 여섯 번째부터는 눈에 들어옵니다. 지금 감이 없는 게 정상이니, 조급해하지 마시고 유형별로 몇 문제씩만 쌓아 보세요.


마무리

오늘은 "다 뒤진다"는 완전탐색의 반대편으로 넘어와, 영리하게 고르고 쪼개는 두 사고법을 익혔습니다. 그리디로 회의실을 배정하며 정렬 기준 하나가 정답을 좌우하는 걸 봤고, 거스름돈 반례로 그리디가 조용히 틀리는 순간을 목격했어요. 분할정복으로는 D-1의 병합 정렬을 다시 만나 그게 애초에 분할정복이었음을 확인했고, 거듭제곱에서 1000번을 11번으로 접었습니다.

오늘 배운 핵심 세 가지

  • 💡 하나 — 그리디는 매 순간의 최선을 집고 되돌아보지 않는다. 갈라지지 않고 한 줄기로 내려오니 2ⁿ이 n으로 접혀 빠르다(정렬이 지배해 대개 O(n log n)). 대신 그 선택이 전체 최적이라는 보장은 따로 증명해야 한다. 도구는 교환 논법이고, 반박은 반례 하나면 끝나니 실전에선 작은 반례부터 던져 본다.
  • 💡 둘 — 분할정복은 반으로 쪼개 정복하고 합친다. D-1의 병합 정렬·퀵 정렬이 이미 이 사고였고, 거듭제곱은 곱셈 1000번을 호출 11번으로 접었다. 비용은 깊이 × 각 깊이의 일이라 O(n log n)·O(log n)이 유도된다. 그 이득은 오직 균형 있게 쪼개진다는 보장에서 오고, 보장이 깨지면(치우친 피벗·중복 호출) 이득도 통째로 사라진다.
  • 💡 셋 — 그리디는 빠르지만 틀릴 수 있고, 완전탐색은 맞지만 느리다. [1,3,4]로 6원을 거스르는 문제 앞에서 두 사고법이 나란히 무너졌다. 하나는 3개라는 틀린 답을, 하나는 지수 시간을 냈다. 둘 사이에 세 번째 길이 필요하다.

돌아보면 오늘의 한 줄기는 "영리하게 고른다(그리디) → 영리하게 쪼갠다(분할정복) → 그런데 둘 다 안 통하는 문제가 있다"였어요. E-1의 완전탐색부터 여기까지, 우리는 도구를 하나씩 늘려 왔습니다. 그리고 마지막 Step에서 마주친 그 빈틈이, 정확히 다음 시간의 출발점입니다.

다음 시간 예고

다음 시간(E-4)엔 "문제를 푸는 다섯 사고법"의 마지막, 동적 계획법(dynamic programming, DP) 기초를 만납니다. 출발점은 오늘 Step 4에서 본 그 장면이에요. 거스름돈 완전탐색 나무에서 같은 남은 금액이 여러 갈래에서 되풀이 계산되던 것 — 이걸 중복 부분 문제(overlapping subproblems)라고 부릅니다. 겹치는 조각이 있다는 건, 한 번 푼 걸 다시 풀고 있다는 뜻이죠.

그래서 하는 일은 딱 하나입니다. "한 번 푼 건 적어 두고 다시 쓴다." 이 단순한 생각에 이름이 두 개 붙어요. 위에서 내려오며 필요할 때 계산하고 답을 적어 두는 메모이제이션(memoization, top-down), 아래 작은 문제부터 표를 차곡차곡 채워 올라가는 타뷸레이션(tabulation, bottom-up). 둘은 같은 생각의 두 방향입니다.

다만 DP의 진짜 관문은 따로 있어요. 점화식 세우기입니다. "지금 문제의 답을 더 작은 문제의 답으로 어떻게 표현할 것인가" — 이 한 줄만 세우면 코드는 저절로 나오고, 못 세우면 아무것도 못 합니다. 그래서 피보나치·계단 오르기·타일링 같은 대표 문제로 점화식 세우는 법을 하나씩 익힐 거예요.

그 대가는 놀랍습니다. 지수 시간이 다항 시간으로 내려앉아요. 오늘 손도 못 대던 거스름돈이 눈 깜짝할 사이에 풀립니다. 마지막으로 한 문장만 쥐고 가세요. 그리디는 매 순간의 최적을 믿고, DP는 전체 최적을 계산한다. 오늘 그리디가 왜 틀렸는지 아셨으니, 다음 시간엔 틀리지 않는 법을 배웁니다.


과제

오늘 배운 그리디와 분할정복을 손에 붙이는 문제들입니다. 코드베이스 algorithms/greedy_divide.py의 함수를 가져다 쓰거나 직접 짜서 풀어 보세요. 각 문제의 시간·공간 복잡도를 빅오로 함께 적는 걸 잊지 마세요.

[기초] 곱하기 혹은 더하기

숫자로만 이루어진 문자열 s가 주어질 때, 이웃한 두 숫자 사이마다 × 또는 +를 하나씩 넣어 만들 수 있는 가장 큰 수를 구하는 max_result(s)를 작성하세요. 단 일반적인 연산 순서는 무시하고 왼쪽에서 오른쪽으로 순서대로 계산합니다. 예를 들어 max_result("02984")576(0+2 → ×9 → ×8 → ×4), max_result("567")210(5×6 → ×7), max_result("0")0입니다.

  • 두 수를 곱하는 게 늘 이득일까요? 0이나 1이 끼면 어떻게 되는지 손으로 계산해 보세요.
  • 매 숫자에서 "지금 곱할까 더할까"만 결정하고 되돌아보지 않으면 그리디입니다. 이 기준이 왜 최적인지 Step 2의 교환 논법으로 한 문장 말해 보세요.
  • 시간·공간 복잡도를 빅오로 적으세요. 정렬이 필요 없는 그리디라 Step 1의 O(n log n) 골격과 다르다는 게 힌트입니다.

[응용] 구명보트

사람들의 몸무게 목록 people과 보트 한 대가 견디는 무게 limit이 주어집니다. 보트 한 대에는 최대 두 명까지 탈 수 있고, 두 명이 함께 타려면 몸무게 합이 limit 이하여야 합니다. 모두를 태우는 데 필요한 최소 보트 수를 구하는 min_boats(people, limit)을 작성하세요. 예를 들어 min_boats([70, 50, 80, 50], 100)3, min_boats([70, 80, 50], 100)3, min_boats([40, 50, 60, 90], 100)3입니다.

  • 정렬해 놓고 가장 가벼운 사람과 가장 무거운 사람을 짝지어 보세요. D-4의 투 포인터가 그대로 돌아옵니다(양 끝에서 좁혀 오기).
  • 가장 무거운 사람은 어차피 혼자 타거나 가장 가벼운 사람과만 탈 수 있습니다. 그렇다면 "가장 무거운 사람부터 처리한다"가 왜 손해를 안 보는지 교환 논법으로 따져 보세요.
  • 정렬 O(n log n)에 투 포인터 O(n)이면 전체는 얼마인가요? 공간은요?

[심화] 색종이 만들기

grid는 0(흰색)과 1(파란색)으로 채워진 2ⁿ × 2ⁿ 정사각형 격자입니다(2×2, 4×4, 8×8 …). 이 종이를 다음 규칙으로 자릅니다. 격자 전체가 한 색이면 자르지 않고 그대로 한 장. 아니면 정확히 4등분해 각 조각에 같은 규칙을 되풀이합니다. 최종적으로 나오는 흰 종이 수와 파란 종이 수를 튜플 (흰색, 파란색)으로 돌려주는 count_papers(grid)를 작성하세요. 예를 들어 count_papers([[1, 1], [1, 1]])(0, 1)이고(전부 파란색이라 자르지 않고 한 장), count_papers([[0, 1], [1, 0]])(2, 2)입니다(색이 섞여 4등분 — 1×1 네 장).

  • Step 5의 분할정복 세 걸음을 그대로 얹으세요. "한 색인가?"가 종료 조건, "4등분해 각각 세기"가 분할과 정복, "네 조각의 답을 더하기"가 병합입니다.
  • 격자를 4등분할 때 새 리스트를 만들지 말고 좌표 범위(시작 행·시작 열·한 변 길이)를 재귀로 넘기면 훨씬 깔끔합니다. E-2의 격자 좌표 감각이 여기서 쓰여요.
  • 병합 정렬은 반으로 쪼갰지만 이건 넷으로 쪼갭니다. 그래도 분할정복일까요? 시간 복잡도를 빅오로 적고, 왜 그렇게 나오는지 Step 7의 "깊이 × 각 깊이의 일"로 설명해 보세요.

생각해볼 주제

1. 코딩테스트 현장에서 그리디를 언제 믿을 것인가

오늘 그리디의 정당성을 교환 논법으로 따져 봤지만, 실제 시험장에선 증명에 쓸 시간이 없습니다. 그런데 확신 없이 짰다가 "일부 케이스 실패"를 받으면 시간 초과보다 훨씬 잡기 어렵죠. 제한 시간 안에서 "이 그리디를 믿고 짤지, 안전한 완전탐색으로 갈지"를 무엇으로 판단하시겠어요? 입력 크기 n이 이 판단에 어떻게 끼어드는지 함께 엮어 정리해 보세요.

2. 같은 분할정복인데 왜 어떤 건 무너지나

오늘 분할정복의 이득이 "균형 있게 쪼개진다는 보장"에서 온다는 걸 두 번 봤습니다. 퀵 정렬은 피벗이 치우치면 O(n²)로 무너졌고, 거듭제곱은 절반을 두 번 부르면 O(log n)이 O(n)으로 무너졌죠. 이 두 붕괴는 겉보기엔 달라 보이는데, 뿌리가 같은 걸까요 다른 걸까요? 그리고 실무의 정렬 라이브러리는 이 붕괴를 어떻게 막고 있을지도 함께 생각해 보세요.

3. 직접 짤 것인가, 내장을 쓸 것인가

오늘 power를 손으로 짰지만 파이썬엔 이미 pow**가 있고, 이들도 같은 분할정복으로 돌아 빅오가 같은 데다 C로 구현돼 더 빠릅니다. 회의실 배정의 정렬도 내장 sorted에 맡겼고요. 그렇다면 직접 구현을 배우는 값어치는 어디에 있을까요? 실전에서 내장을 믿고 쓸 때와 직접 짜야 할 때를 무엇으로 가를지 정리해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 곱하기 혹은 더하기

채점 포인트

항목 확인
0·1 함정 곱셈이 늘 이득이 아니라는 걸 알아챘는가
판단 기준 누적값이나 다음 숫자가 1 이하면 더하고, 둘 다 2 이상이면 곱한다
정당성 왜 이 선택이 손해를 안 보는지 한 문장으로 말할 수 있는가
정렬 없는 그리디 집을 순서가 이미 정해져 있어 정렬 단계가 없다는 이해
빅오 시간 O(n)·공간 O(1)

풀이 예시

이 문제의 함정은 "곱셈이 덧셈보다 크다"는 직관입니다. 대부분 맞지만 두 숫자에서 뒤집혀요. 0을 곱하면 지금까지 쌓아 온 값이 통째로 날아가고, 1을 곱하면 제자리입니다. 그러니 기준은 하나예요. 지금까지 쌓은 값이나 다음 숫자가 0이나 1이면 더하고, 둘 다 2 이상이면 곱합니다.

Python

def max_result(s):
    result = int(s[0])
    for char in s[1:]:
        digit = int(char)
        if result <= 1 or digit <= 1:       # 0을 곱하면 다 날아가고, 1을 곱하면 제자리다
            result += digit
        else:
            result *= digit                 # 둘 다 2 이상이면 곱하는 게 늘 이득
    return result

"둘 다 2 이상이면 곱하는 게 늘 이득"은 감이 아니라 한 줄로 증명됩니다. a와 b가 둘 다 2 이상이면 (a-1)(b-1) >= 1이죠. 이걸 펴면 ab - a - b + 1 >= 1, 곧 ab >= a+b입니다. 곱한 값이 더한 값보다 작을 수 없어요.

텍스트
 max_result("02984") — 왼쪽에서 오른쪽으로 순서대로 계산한다

   result = 0
   0 vs 2     하나가 1 이하    0 + 2   = 2
   2 vs 9     둘 다 2 이상     2 × 9   = 18
   18 vs 8    둘 다 2 이상     18 × 8  = 144
   144 vs 4   둘 다 2 이상     144 × 4 = 576

그럼 정당성은요? Step 2의 교환 논법으로 따져 봅시다. 앞으로 남은 연산은 전부 +d 아니면 ×d이고 d는 0 이상입니다. 어느 쪽이든 쌓인 값이 클수록 결과도 크지, 작아지는 일이 없어요. 그러니 매 숫자에서 값을 최대로 키워 두면 나중에 손해 볼 일이 없고, 그 한 번의 최선이 그대로 전체의 최선이 됩니다. 되돌아볼 이유가 없으니 그리디가 통해요.

빅오는 시간 O(n)·공간 O(1)입니다. 문자열을 한 번 훑고 누적값 하나만 들고 가니까요. 여기서 눈여겨볼 게 있어요. Step 1에서 그리디의 표준 골격을 "정렬 → 훑기 = O(n log n)"이라고 했는데, 이 문제엔 정렬이 없습니다. 집을 순서가 왼쪽에서 오른쪽으로 이미 정해져 있어 줄 세울 게 없거든요. 그리디라고 다 정렬로 시작하는 건 아닙니다.

흔한 실수 세 가지를 짚을게요. 첫째, 무조건 곱하기. 둘째, 0만 처리하고 1을 놓치기. 셋째, 첫 숫자가 1일 때를 놓치기입니다. max_result("123")이 정확히 그 함정이에요. 무조건 곱하면 1×2×3 = 6이지만, 1을 더하면 1+2 = 3, ×3 = 9가 나옵니다. "111"은 곱해 봐야 1이라 전부 더해 3이고요.

💡 튜터의 한마디: 백준 1543 "곱하기 혹은 더하기"가 이 문제 그대로예요. 여기서 챙길 감각은 "그리디의 판단 기준은 예외에서 나온다"는 겁니다. 규칙의 90%(곱하기)는 누구나 떠올려요. 합격을 가르는 건 나머지 10%(0과 1)를 손으로 계산해 보고 발견하느냐죠. 그리고 이 문제처럼 정렬이 없는 그리디도 흔합니다. "그리디 = 정렬"로 외우지 말고, "매 순간 최선을 집고 되돌아보지 않는다"는 본질로 기억하세요.


🎯 [과제 2 예시답안] 구명보트

채점 포인트

항목 확인
정렬 + 투 포인터 D-4의 양 끝에서 좁혀 오기를 꺼내 썼는가
짝짓기 기준 가장 무거운 사람과 가장 가벼운 사람을 맞붙였는가
정당성 교환 논법으로 "바꿔도 손해가 없다"를 설명할 수 있는가
경계 처리 left < right로 마지막 한 명의 중복 계산을 막았는가
빅오 시간 O(n log n)(정렬 지배)·공간 O(n)

풀이 예시

"최소 보트 수"를 물으니 그리디 후보고, "가장 무거운 사람과 가장 가벼운 사람"을 짝지으라는 힌트에서 D-4의 투 포인터가 떠올라야 합니다. 정렬해 놓고 양 끝에서 좁혀 오는 그 패턴이 그대로 돌아와요.

Python
# algorithms/exercises_e3.py
def min_boats(people, limit):
    ordered = sorted(people)
    left, right = 0, len(ordered) - 1
    boats = 0
    while left <= right:
        if left < right and ordered[left] + ordered[right] <= limit:
            left += 1                       # 가벼운 사람도 함께 태운다 (두 명이 한 보트)
        right -= 1                          # 무거운 사람은 어느 쪽이든 이 보트로 나간다
        boats += 1
    return boats

코드에 미묘한 부분이 둘 있어요. 하나는 right -= 1boats += 1if 바깥에 있다는 겁니다. 왜냐면 가장 무거운 사람은 함께 타든 혼자 타든 어느 쪽이든 이 보트로 나가거든요. 갈리는 건 "가벼운 사람을 태워 보낼까"뿐이라 left += 1만 조건 안에 있습니다. 다른 하나는 left < right예요. 이게 없으면 마지막 한 명이 남았을 때 자기 자신과 짝지어져 left가 넘어가 버립니다.

텍스트
 min_boats([70, 50, 80, 50], 100) — 정렬하면 [50, 50, 70, 80]

   left=0(50)  right=3(80)    50+80 = 130 > 100    80 혼자     보트 1
   left=0(50)  right=2(70)    50+70 = 120 > 100    70 혼자     보트 2
   left=0(50)  right=1(50)    50+50 = 100 <= 100   둘이 함께   보트 3
   left=1 > right=0    종료                                     답 3

이제 정당성입니다. 오늘 답안의 핵심이니 천천히 따라오세요. 출발점은 이 관찰이에요. 가장 무거운 사람 H는 어차피 혼자 타거나 딱 한 명과만 탑니다. 그럼 그 한 명을 가장 가벼운 사람 L로 잡아도 될까요?

교환 논법으로 따져 봅시다. 어떤 최적해에서 H가 X와, L이 Y와 탄다고 하죠. 이걸 (H, L)과 (X, Y)로 바꿔치기해 봅니다. L <= XH+L <= H+X <= limit이라 H와 L은 함께 탈 수 있어요. 남는 (X, Y)도 H가 가장 무거우니 Y <= H, 따라서 X+Y <= X+H <= limit이라 역시 함께 탑니다. 보트 수는 그대로예요. 바꿔도 손해가 없으니 "가장 무거운 사람을 가장 가벼운 사람과 짝지어 먼저 내보낸다"는 그리디가 최적해를 깨지 않습니다. L과도 못 탈 만큼 무겁다면 H는 혼자 탈 수밖에 없으니 이 역시 최선이고요.

빅오는 시간 O(n log n)·공간 O(n)입니다. 투 포인터 자체는 두 포인터가 만날 때까지 한 번 좁혀 오니 O(n)뿐인데, 앞의 정렬이 O(n log n)이라 정렬이 전체를 지배해요. 정렬은 내장 sorted에 맡깁니다(Step 7의 Timsort). 공간 O(n)은 그 sorted가 새 리스트를 만들기 때문이고, 덕분에 입력은 보존됩니다.

흔한 실수는 가벼운 사람끼리 짝짓기예요. 정렬 후 앞에서부터 둘씩 묶는 건데, [10, 10, 20, 90]·100에 넣어 보면 곧장 깨집니다. 앞에서부터 묶으면 (10,10) 한 대, 그다음 20과 90은 합이 110이라 각각 한 대씩 — 3대예요. 그런데 가벼운 사람을 무거운 사람에게 붙이면 (10,90) 한 대, (10,20) 한 대로 2대면 됩니다. 가벼운 사람끼리 묶어 버리면 정작 무거운 사람 옆자리가 비어 보트가 남아돌아요. left < right를 빠뜨려 마지막 한 명을 두 번 세는 것, 보트에 3명을 태우는 것도 단골입니다.

💡 튜터의 한마디: 프로그래머스 "구명보트"(Lv2)가 이 문제 그대로고, 코딩테스트 그리디 유형의 대표예요. 챙길 감각은 "제약이 가장 심한 것부터 처리한다"는 겁니다. 가장 무거운 사람은 선택지가 가장 좁아요(혼자 아니면 가장 가벼운 사람과). 선택지가 좁은 쪽부터 확정하면 남은 문제가 단순해집니다. 이 발상은 그리디 문제 전반에 통해요. 그리고 오늘 정렬 + 투 포인터 조합을 보셨듯, 그리디는 앞서 배운 패턴 위에 얹히는 경우가 많습니다.


🎯 [과제 3 예시답안] 색종이 만들기

채점 포인트

항목 확인
종료 조건 "한 색인가?"가 종료 조건이라는 걸 잡았는가
분할·병합 4등분해 재귀하고, 네 조각의 답을 더했는가
좌표 전달 새 리스트를 뜨지 않고 좌표 범위만 넘겼는가
넷으로 쪼개기 반이 아니어도 분할정복인 이유를 설명할 수 있는가
빅오 시간 O(n²)·공간 O(log n) + 왜 n² log n이 아닌지

풀이 예시

Step 5의 분할정복 세 걸음이 1:1로 얹히는 문제입니다. "한 색인가?"가 종료 조건, 4등분해 자기를 다시 부르는 게 분할과 정복, 네 조각의 답을 더하는 게 병합이에요.

Python
# algorithms/exercises_e3.py
def count_papers(grid):
    def is_uniform(row, col, size):
        first = grid[row][col]
        for r in range(row, row + size):
            for c in range(col, col + size):
                if grid[r][c] != first:     # 다른 색을 만나면 즉시 멈춘다 — 더 볼 것 없다
                    return False
        return True

    def cut(row, col, size):
        if is_uniform(row, col, size):      # 종료 조건: 한 색이면 자르지 않고 한 장
            return (1, 0) if grid[row][col] == 0 else (0, 1)
        half = size // 2
        white = blue = 0
        for dr in (0, half):                # 분할·정복: 4등분해 각 조각을 같은 규칙으로
            for dc in (0, half):
                w, b = cut(row + dr, col + dc, half)
                white += w                  # 병합: 네 조각의 답을 더한다
                blue += b
        return white, blue

    return cut(0, 0, len(grid))

격자 조각을 새 리스트로 뜨지 않은 이유부터 짚을게요. 슬라이싱으로 4등분해 넘기면 그 복사 비용이 고스란히 붙습니다. 대신 좌표 범위(시작 행·시작 열·한 변 길이)만 넘기고 원본 격자를 함께 봐요. cutis_uniform을 중첩 함수로 둔 것도 그래서입니다. grid를 매번 인자로 넘길 필요 없이 바깥 것을 그대로 참조하니까요. E-2에서 미로를 좌표로 훑던 감각 그대로예요.

텍스트
 count_papers([[0, 1], [1, 0]])

   2×2 전체    한 색인가? 아니오    4등분
     ├ (0,0) 1×1    흰색     (1, 0)
     ├ (0,1) 1×1    파랑     (0, 1)
     ├ (1,0) 1×1    파랑     (0, 1)
     └ (1,1) 1×1    흰색     (1, 0)
   병합: 흰 2, 파랑 2    (2, 2)

과제 힌트가 던진 질문 — "병합 정렬은 반으로 쪼갰는데 이건 넷으로 쪼갠다. 그래도 분할정복인가?" 답은 그렇다입니다. Step 8에서 분할정복의 판정 조건을 둘로 정리했죠. ① 쪼갠 게 원래와 같은 모양의 문제인가 ② 부분 답을 합칠 수 있는가. "반으로"는 조건에 없어요. 4등분한 조각도 여전히 색종이를 세는 문제고, 네 답은 더하면 합쳐집니다. 둘 다 예스니 분할정복이에요.

이제 빅오입니다. 여기가 오늘 과제의 하이라이트예요. 답은 시간 O(n²)·공간 O(log n)(n은 한 변의 길이라 격자의 칸 수가 n²)인데, Step 7의 "깊이 × 각 깊이의 일"로 어림하면 이상한 게 나옵니다.

텍스트
 어림 계산 — 곱하면 n² log n이 나오는데?

   한 변이 반씩 접히니          깊이  = log₂n
   한 깊이의 조각들은 격자를    각 깊이의 일 = n²
   나눠 가지니 칸을 다 합치면
                              ────────────────────
                              곱하면 n² log n (?)

그런데 실제론 그 곱이 다 들지 않습니다. 이유가 둘이에요. 첫째, 한 색인 조각은 거기서 멈춰 그 아래 깊이가 통째로 사라집니다. 8×8 예시의 오른쪽 아래 4×4가 통째로 파란색이라 한 장으로 끝나듯이요. 둘째, 섞인 조각은 다른 색을 만나는 순간 확인을 멈춰 끝까지 훑지 않습니다(is_uniformreturn False). 두 절약이 맞물려 최악에도 각 칸을 상수 번 보는 데 그치니, 칸 수에 비례하는 O(n²)이에요.

그리고 이보다 빠를 수도 없습니다. 어차피 모든 칸을 적어도 한 번은 봐야 답을 알 수 있으니까요. 칸 하나를 안 보고 넘겼는데 그게 다른 색이면 답이 통째로 틀리잖아요. 공간 O(log n)은 재귀 스택 깊이입니다 — 한 변이 1이 될 때까지 반씩 접히는 log₂n 걸음이고, 격자 자체는 복사하지 않으니 거기 붙는 비용이 없어요.

실행 예시로 확인하면 count_papers([[1, 1], [1, 1]])(0, 1)(전부 파란색이라 안 자름), count_papers([[0, 1], [1, 0]])(2, 2), 코드베이스의 8×8 예시는 (9, 7)입니다.

흔한 실수 셋을 짚을게요. 첫째, 슬라이싱으로 새 격자를 만들어 넘기기(복사 비용이 붙습니다). 둘째, 종료 조건을 size == 1로만 두기 — 이러면 한 색이어도 끝까지 쪼개져 [[1,1],[1,1]](0, 4)가 나옵니다. 정답은 (0, 1)이죠. 셋째, 흰색·파란색 순서 뒤바꾸기입니다. 0이 흰색이라 (흰색, 파란색) 순서를 끝까지 지켜야 해요.

💡 튜터의 한마디: 백준 2630 "색종이 만들기"가 이 문제 그대로고, 넷이 아니라 아홉으로 쪼개는 변형이 백준 1780 "종이의 개수"예요. 규칙만 바뀌고 골격은 똑같습니다. 챙길 감각은 "쪼개는 수는 본질이 아니다"입니다. 둘이든 넷이든 아홉이든, "같은 문제로 쪼개지나 · 합칠 수 있나" 둘만 통과하면 분할정복이에요. 그리고 재귀에 격자를 통째로 넘기지 말고 좌표만 넘기는 습관은 앞으로 격자 재귀 문제마다 쓰입니다.


🤔 [생각해볼 주제 1] 코딩테스트 현장에서 그리디를 언제 믿을 것인가

문제 상황 요약

오늘 교환 논법으로 그리디의 정당성을 따져 봤지만, 실제 시험장에서 종이에 증명을 쓸 시간은 없습니다. 그렇다고 확신 없이 짰다가 "일부 케이스 실패"를 받으면 시간 초과보다 훨씬 잡기 어렵죠. 제한 시간 안에서 "이 그리디를 믿고 짤지, 안전한 완전탐색으로 갈지"를 무엇으로 판단해야 할까요?

튜터의 가이드 및 해설

판단의 1차 필터는 증명이 아니라 입력 크기 n입니다. 문제에서 가장 먼저 볼 건 제약 조건이에요. A-1의 "1초에 1억 연산" 잣대를 들이대면 후보가 자동으로 걸러집니다. n이 100만이면 O(n log n) 이하만 살아남으니 완전탐색과 DP는 애초에 후보에서 빠지고 그리디·정렬 계열만 남아요. 반대로 n이 20 남짓이면 O(2ⁿ)이 백만 정도라 여유롭게 통과하니, 출제자가 대놓고 "완전탐색 하세요"라고 말하는 셈입니다. 제약 조건이 풀이 방향을 절반쯤 알려 주는 거예요.

n으로 후보를 좁혔으면 그다음이 검증인데, 순서가 중요합니다. 증명이 아니라 반례를 먼저 던지세요. Step 2에서 본 비대칭 그대로예요. "맞다"를 보이려면 모든 입력을 논증해야 하지만, "틀리다"는 입력 하나면 끝납니다. 원소 2~3개짜리 작은 입력으로 30초만 손계산해 보세요. 깨지면 그리디는 즉시 폐기고, 코드 30분을 아낀 겁니다.

30초를 던졌는데도 안 깨지면 그때 한 문장을 시도합니다. "이 기준으로 집으면 왜 손해를 안 보지?" 이 질문에 한 문장으로 답이 나오면 그게 교환 논법의 축약판이에요. 오늘 구명보트에서 "가장 무거운 사람은 어차피 혼자 타거나 한 명과만 탄다"가 정확히 그 한 문장이었죠. 문장이 안 나오면 확신이 없다는 신호고, 그때 n을 다시 봅니다.

여기서 현실적인 결론이 나와요. 확신이 없고 n이 작다면, 안전한 완전탐색이 오히려 이득입니다. 그리디로 갔다가 "일부 케이스 실패"를 받으면 코드 버그인지 접근 자체가 틀린 건지 알 수가 없어요. 시간 초과는 최소한 "느리다"는 정보라도 주지만, 부분 실패는 아무 정보도 안 줍니다. 디버깅으로 한 시간을 태우고 나서야 접근이 틀렸음을 깨닫는 거죠. 반면 완전탐색은 느릴 뿐 답은 맞아요.

물론 이건 정답 공식이 아니라 시간을 아끼는 한 가지 방법입니다. 정리하면 ① 제약 조건으로 후보 좁히기 → ② 작은 반례 30초 → ③ 안 깨지면 "왜 손해를 안 보지?" 한 문장 → ④ 문장이 안 나오고 n이 작으면 완전탐색 순서예요.

🎯 코테·면접에선 이렇게 나온다

"이 문제를 그리디로 푸셨는데, 그리디가 맞다는 걸 어떻게 확신했나요?"가 단골입니다. "직관적으로 그럴 것 같아서요"는 최악의 답이에요. "가장 무거운 사람은 선택지가 가장 좁아서, 그 짝을 가장 가벼운 사람으로 바꿔도 보트 수가 늘지 않는다는 걸 확인했습니다"처럼 교환 논법의 축약판을 한 문장으로 말하면 됩니다. 여기에 "제약이 n ≤ 100만이라 O(n log n) 이하만 가능해 그리디 계열부터 검토했다"를 얹으면, 제약 조건을 읽고 접근을 설계한다는 인상까지 줘요.

💡 실무에선

"확신 없는 최적화를 언제 밀어붙일 것인가"의 판단으로 이어집니다. 실무에서도 "이렇게 하면 빠를 것 같은데"라는 직관이 늘 있고, 그게 맞을 때도 틀릴 때도 있어요. 차이는 틀렸을 때의 비용입니다. 느린 코드는 눈에 보이지만, 조용히 틀린 답을 내는 코드는 몇 달 뒤 데이터가 어긋난 걸 발견하고서야 드러나요. 그래서 "빠르지만 조건부로 맞는" 최적화를 넣을 땐 그 조건이 무엇인지 명시하고, 조건이 깨질 수 있으면 안전한 쪽을 고릅니다. 오늘 거스름돈이 정확히 그 이야기였어요 — 한국 동전에선 맞지만 그건 동전 체계 덕이지 그리디 덕이 아니었죠.


🤔 [생각해볼 주제 2] 같은 분할정복인데 왜 어떤 건 무너지나

문제 상황 요약

오늘 분할정복의 이득이 "균형 있게 쪼개진다는 보장"에서 온다는 걸 두 번 봤습니다. 퀵 정렬은 피벗이 치우치면 O(n²)로 무너졌고, 거듭제곱은 절반을 두 번 부르면 O(log n)이 O(n)으로 무너졌죠. 이 두 붕괴는 겉보기엔 달라 보이는데, 뿌리가 같은 걸까요?

튜터의 가이드 및 해설

결론부터 말하면 뿌리가 같습니다. 분할정복의 이득은 오직 하나에서 나와요. "쪼갤 때마다 문제가 절반으로 접힌다"는 보장이죠. log n이라는 마법의 출처가 정확히 거기입니다. 그러니 이 보장이 어떤 방식으로든 깨지면 이득도 통째로 사라져요. 퀵 정렬과 거듭제곱은 그 보장이 깨지는 두 가지 방식일 뿐입니다.

다만 깨지는 방향이 다르다는 건 짚고 갈 만해요. 퀵 정렬은 분할이 불균형해집니다. 피벗이 매번 끝값이면 원소가 하나씩만 떨어져 나가 깊이가 log n이 아니라 n이 돼요. 반면 거듭제곱은 분할 자체는 멀쩡한데 같은 일을 두 번 합니다. power(...) * power(...)로 쓰면 호출이 매번 두 배로 갈라져 2^(log exp) = exp개가 되죠.

텍스트
 보장이 깨지는 두 방향

   퀵 정렬     : 깊이가 늘어난다   (세로로 무너짐)
                 log n    n           불균형 분할

   거듭제곱    : 폭이 늘어난다     (가로로 무너짐)
                 호출 1개    2개씩 갈라짐   중복 호출

   결론은 같다 : 쪼갠 이득이 사라지고 원래 복잡도로 되돌아간다

하나는 세로로, 하나는 가로로 무너지지만 도착점은 같아요. 껍데기만 분할정복이면 빅오는 정직하게 무너집니다. 그래서 분할정복 코드를 짜고 나면 이 질문 하나를 던지세요. "정말 반으로 접히고 있나? 그리고 그 반쪽을 한 번만 부르고 있나?"

그럼 실무의 정렬 라이브러리는 이 붕괴를 어떻게 막을까요? 전략이 크게 둘입니다. 하나는 최악을 확률적으로 피하기예요. 퀵 정렬을 쓰는 언어들은 피벗을 맨 끝에서 고정으로 집지 않고 무작위로 고르거나, 세 값의 중앙값으로 고릅니다(median-of-three). 그러면 "이미 정렬된 입력"처럼 특정 입력이 늘 최악을 때리는 일이 사라져요. 다른 하나는 아예 다른 알고리즘으로 도망치기입니다. 재귀가 너무 깊어지면(= 분할이 치우치고 있다는 신호) 힙 정렬로 갈아타는 인트로소트(introsort)가 그거예요. 최악이 O(n²)로 가는 길을 아예 차단합니다.

파이썬은 세 번째 길을 갔어요. sortedTimsort는 퀵이 아니라 병합 정렬과 삽입 정렬의 하이브리드라 최악에도 O(n log n)이 보장됩니다. 게다가 안정 정렬이고, 현실 데이터에 흔한 이미 정렬된 구간(run)을 찾아내 살려 쓰니 이미 정렬된 입력에선 오히려 더 빨라요. 퀵 정렬이 그 입력에서 최악이 되는 것과 정반대죠. 어느 전략이든 공통점은 하나입니다. "보장이 깨질 수 있다는 걸 알고 미리 대비했다."

🎯 코테·면접에선 이렇게 나온다

"퀵 정렬의 최악은 언제인가요?"는 단골 중의 단골입니다. "이미 정렬된 입력이요"까지는 절반이에요. "맨 끝을 피벗으로 쓰면 그게 늘 최댓값이라 1 대 (n-1)로 갈라져 깊이가 n이 되고, 각 깊이의 partition이 O(n)이라 O(n²)가 된다. 그래서 실무 구현은 피벗을 무작위·중앙값으로 고르거나 인트로소트로 힙 정렬에 넘긴다"까지 가면 원인과 처방을 다 아는 답입니다. "그럼 파이썬 sorted는요?"가 따라오면 Timsort의 최악 보장으로 받으면 돼요.

💡 실무에선

"평균은 좋은데 최악이 나쁜" 도구를 언제 쓸 것인가의 판단으로 이어집니다. 퀵 정렬만의 이야기가 아니에요. 해시 테이블도 평균 O(1)이지만 충돌이 몰리면 O(n)이고, 캐시도 적중하면 빠르지만 전부 빗나가면 오히려 느립니다. 실무에서 무서운 건 평균에 맞춰 설계했는데 최악이 실제로 터지는 순간이에요. 게다가 최악이 무작위로 오지 않고 특정 입력 패턴에서 몰려 오는 경우가 많습니다(이미 정렬된 데이터, 같은 키로 몰리는 해시). 그래서 "이 도구의 최악은 언제인가, 그 입력이 우리 서비스에서 실제로 나올 수 있는가"를 묻는 습관이 필요해요.


🤔 [생각해볼 주제 3] 직접 짤 것인가, 내장을 쓸 것인가

문제 상황 요약

오늘 power를 손으로 짰지만 파이썬엔 이미 pow**가 있습니다. 이들도 같은 분할정복으로 돌아 빅오가 같은 데다 C로 구현돼 더 빠르죠. 회의실 배정의 정렬도 내장 sorted에 맡겼고요. 그렇다면 직접 구현을 배우는 값어치는 어디에 있을까요?

튜터의 가이드 및 해설

먼저 김빠지는 결론부터 인정하고 갑시다. 실전에선 내장을 씁니다. pow·**·sorted는 빅오가 같고 C 구현이라 더 빠르며, 무엇보다 버그가 없어요. 시험장에서 power를 직접 짜는 건 시간 손해고 실수 위험만 늘립니다. 이걸 인정 안 하고 "배웠으니 직접 짜야지"로 가면 그게 오히려 함정이에요.

그럼 왜 배웠을까요? 값어치가 세 군데 있습니다.

첫째, 변형이 필요할 때 원리가 없으면 못 짭니다. pow가 인자를 셋 받는다는 것 아세요? pow(base, exp, mod)는 거듭제곱을 하면서 큰 수를 나눈 나머지까지 함께 구해 줘요. 코딩테스트에서 "10^9+7로 나눈 나머지를 출력하시오"가 나오면 이게 결정적입니다. 왜 나머지를 중간중간 취해도 되는지, 왜 그게 O(log n)에 되는지는 오늘 배운 분할정복 골격을 알아야 이해돼요. 정렬도 같습니다. 커스텀 비교나 최대 힙 트릭(값에 음수를 붙여 넣기) 같은 변형은 내부가 어떻게 도는지 알아야 나옵니다.

둘째, 면접에서 물어봅니다. "거듭제곱을 O(log n)에 어떻게 구하나요"·"병합 정렬과 퀵 정렬의 차이는요"는 단골이에요. pow 쓰면 된다고 답할 순 없죠. 답의 뿌리는 오늘 손으로 짠 코드에 있습니다.

셋째, 그리고 가장 중요한데 — 원리를 알아야 라이브러리를 언제 믿고 언제 의심할지 판단합니다. 주제 2가 정확히 이 이야기였어요. "이 데이터는 이미 거의 정렬돼 있는데 sorted가 괜찮을까?"라는 질문에 답하려면 Timsort가 run을 살려 쓴다는 걸 알아야 하고, "퀵 정렬 쓰는 언어에서 정렬된 입력을 넣으면?"에 답하려면 피벗 이야기를 알아야 합니다. 내장을 믿고 쓰는 것과 모르고 쓰는 건 결과가 같아 보이지만, 문제가 터졌을 때 갈립니다.

가르는 기준을 한 줄로 정리하면 이렇습니다. 표준 라이브러리에 있고 변형이 필요 없으면 내장을 쓰세요. 없거나, 변형이 필요하거나, 면접에서 원리를 묻거든 직접 짜는 겁니다. 그러니 이 과목의 두 트랙 — 직접 구현으로 원리를 보고 실전에선 내장을 쓰는 것 — 은 모순이 아니라 순서예요. 원리를 아는 사람이 라이브러리를 제대로 씁니다.

🎯 코테·면접에선 이렇게 나온다

"이 문제 정렬은 어떻게 하셨어요?"에 "sorted 썼습니다"라고만 하면 밋밋해요. "sorted를 썼습니다. Timsort라 최악에도 O(n log n)이 보장되고 안정 정렬이라 다중 기준 정렬에도 안전하거든요"까지 가면 도구를 골라 쓴다는 인상을 줍니다. 거듭제곱도 마찬가지예요. "pow(a, b, mod)를 썼는데, 내부가 오늘 짠 것과 같은 분할정복이라 O(log b)입니다"라고 답하면, 내장을 쓰되 안을 안다는 게 드러나요. 내장을 쓴 이유를 말할 수 있는 사람이 면접에서 갈립니다.

💡 실무에선

이 판단이 실무에선 훨씬 크게 작동합니다. "직접 짤 것인가, 있는 걸 쓸 것인가"는 정렬 함수부터 인증 라이브러리, 프레임워크 선택까지 매일 마주치는 질문이에요. 기본값은 늘 "검증된 걸 쓴다"입니다. 수많은 사람이 오래 두들겨 본 코드가 혼자 급히 짠 내 코드보다 안전하니까요. 특히 암호화처럼 직접 짜면 위험한 영역은 예외가 없고요. 그래도 원리를 아는 사람은 다릅니다. 라이브러리가 우리 상황에 맞는지 판단할 수 있고, 문제가 터졌을 때 안을 열어 볼 수 있어요. 남이 만든 걸 쓰되 그 안이 어떻게 도는지 아는 것 — 이게 실무에서 오래가는 힘입니다.

전체 목록 자료구조·알고리즘