그리디 알고리즘 완벽 정리 – 활동 선택, 회의실 배정 등 코딩테스트 필수 유형 풀이

Updated Feb 6, 2026

그리디 알고리즘이란?

그리디(Greedy) 알고리즘은 매 순간 가장 좋아 보이는 선택을 하는 알고리즘 설계 기법이다. 전체 최적해를 구하기 위해 각 단계에서 지역적으로 최적인 선택(locally optimal choice)을 반복하며, 이 선택들이 모여 전역 최적해(globally optimal solution)를 이루길 기대한다.

핵심 원리: 그리디 알고리즘이 정당한 최적해를 보장하려면 두 가지 조건이 필요하다.
1. 탐욕적 선택 속성(Greedy Choice Property): 지역 최적 선택이 전역 최적해로 이어진다.
2. 최적 부분 구조(Optimal Substructure): 문제의 최적해가 부분 문제의 최적해를 포함한다.

이 두 조건이 성립하지 않으면 그리디로 풀 수 없다. 코딩테스트에서 그리디를 적용할 때 가장 중요한 건 “왜 이 탐욕적 선택이 최적인가?”를 논리적으로 확인하는 것이다.

그리디 vs DP vs 완전탐색 비교

구분 그리디 동적 프로그래밍(DP) 완전탐색
접근 방식 매 단계 최선의 선택 모든 부분 문제를 해결 후 조합 모든 경우의 수 탐색
시간 복잡도 일반적으로 O(nlogn)O(n \log n) O(n2)O(n^2) 이상 O(2n)O(2^n) 이상
최적해 보장 조건 충족 시에만 항상 보장 항상 보장
되돌아가기 없음 (한 번 선택하면 끝) 이전 결과 재활용 모든 경로 탐색
적용 난이도 정당성 증명이 어려움 점화식 설계가 어려움 구현은 쉬우나 느림

유형 1: 활동 선택 문제 (Activity Selection)

문제 설명

nn개의 활동이 있고, 각 활동은 시작 시간 sis_i와 종료 시간 fif_i를 가진다. 한 번에 하나의 활동만 수행할 수 있을 때, 최대한 많은 활동을 선택하라.

핵심 아이디어

종료 시간이 빠른 활동부터 선택하라. 일찍 끝나는 활동을 먼저 선택하면 남은 시간이 최대화되어 더 많은 활동을 넣을 수 있다.

이것이 그리디가 정당한 이유:
– 종료 시간이 가장 빠른 활동 aka_k를 포함하지 않는 최적해가 있다고 가정하자.
– 그 최적해의 첫 번째 활동을 aka_k로 교체해도 aka_k의 종료 시간이 가장 빠르므로 나머지 활동과 충돌하지 않는다.
– 따라서 aka_k를 포함하는 최적해가 반드시 존재한다. (교환 논증)

난이도별 접근법

난이도 접근법 설명
쉬움 종료 시간 정렬 후 순차 선택 기본 활동 선택
보통 가중치가 있는 활동 선택 DP와 결합 필요
어려움 여러 자원에 활동 배정 우선순위 큐 활용

Python 풀이

def activity_selection(activities):
    """
    활동 선택 문제: 최대한 많은 활동을 선택한다.
    activities: [(시작시간, 종료시간), ...]
    반환: 선택된 활동 리스트
    """
    # 1단계: 종료 시간 기준으로 오름차순 정렬
    sorted_acts = sorted(activities, key=lambda x: x[1])

    selected = [sorted_acts[0]]  # 첫 번째 활동은 무조건 선택
    last_end = sorted_acts[0][1]  # 마지막으로 선택한 활동의 종료 시간

    # 2단계: 나머지 활동을 순회하며 선택 가능한 것만 추가
    for start, end in sorted_acts[1:]:
        if start >= last_end:  # 시작 시간이 마지막 종료 시간 이후면 선택
            selected.append((start, end))
            last_end = end  # 종료 시간 갱신

    return selected


# 예제 실행
activities = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9),
              (6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
result = activity_selection(activities)
print(f"선택된 활동 수: {len(result)}")
print(f"선택된 활동: {result}")

단계별 동작 시각화

정렬 후 활동 목록: (1,4), (3,5), (0,6), (5,7), (3,9), (5,9), (6,10), (8,11), (8,12), (2,14), (12,16)

단계 현재 활동 last_end 선택 여부 이유
1 (1, 4) 4 선택 첫 번째 활동
2 (3, 5) 4 제외 3 < 4 (겹침)
3 (0, 6) 4 제외 0 < 4 (겹침)
4 (5, 7) 7 선택 5 ≥ 4
5 (3, 9) 7 제외 3 < 7 (겹침)
6 (5, 9) 7 제외 5 < 7 (겹침)
7 (6, 10) 7 제외 6 < 7 (겹침)
8 (8, 11) 11 선택 8 ≥ 7
9 (8, 12) 11 제외 8 < 11 (겹침)
10 (2, 14) 11 제외 2 < 11 (겹침)
11 (12, 16) 16 선택 12 ≥ 11

결과: (1,4) → (5,7) → (8,11) → (12,16) — 총 4개 활동 선택

복잡도 분석

  • 시간 복잡도: O(nlogn)O(n \log n) — 정렬이 지배적
  • 공간 복잡도: O(n)O(n) — 정렬된 배열과 결과 저장

자주 하는 실수

  1. 시작 시간으로 정렬: 시작 시간이 빠르다고 좋은 게 아니다. (0, 100)처럼 오래 걸리는 활동이 먼저 선택될 수 있다.
  2. >= vs > 혼동: 종료 시간과 시작 시간이 같은 경우(start >= last_end)를 허용할지 문제 조건을 정확히 읽어야 한다.
  3. 입력이 이미 정렬되어 있다고 가정: 항상 명시적으로 정렬하라.

유형 2: 회의실 배정 문제

문제 설명

nn개의 회의가 있을 때, 하나의 회의실에서 최대한 많은 회의를 진행하려면 어떻게 배정해야 하는가?

본질적으로 활동 선택 문제와 동일하다. 하지만 코딩테스트에서는 약간 다른 형태로 변형되어 출제된다.

변형: 최소 회의실 개수 구하기

더 자주 출제되는 변형은 “모든 회의를 배정하려면 회의실이 최소 몇 개 필요한가?”이다.

이 문제는 스위핑(Sweeping) 기법과 우선순위 큐(힙)를 결합해 풀 수 있다.

핵심 아이디어

시작 시간 순으로 정렬한 뒤, 현재 진행 중인 회의 중 가장 빨리 끝나는 회의실을 재활용한다. 재활용할 수 없으면 새 회의실을 추가한다.

Python 풀이: 하나의 회의실에 최대 회의 수

def max_meetings_one_room(meetings):
    """
    하나의 회의실에서 최대한 많은 회의를 배정한다.
    meetings: [(시작, 종료), ...]
    """
    # 종료 시간 기준 정렬 (활동 선택과 동일)
    sorted_meetings = sorted(meetings, key=lambda x: x[1])

    count = 1
    last_end = sorted_meetings[0][1]

    for start, end in sorted_meetings[1:]:
        if start >= last_end:
            count += 1
            last_end = end

    return count


# 예제: 백준 1931번 스타일
meetings = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9),
            (6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
print(f"최대 회의 수: {max_meetings_one_room(meetings)}")  # 4

Python 풀이: 최소 회의실 개수

import heapq

def min_meeting_rooms(meetings):
    """
    모든 회의를 배정하기 위한 최소 회의실 개수를 구한다.
    meetings: [(시작, 종료), ...]
    """
    if not meetings:
        return 0

    # 1단계: 시작 시간 기준으로 정렬
    sorted_meetings = sorted(meetings, key=lambda x: x[0])

    # 2단계: 최소 힙으로 각 회의실의 종료 시간 관리
    # 힙에는 현재 사용 중인 회의실의 종료 시간이 들어간다
    rooms = []  # min-heap of end times
    heapq.heappush(rooms, sorted_meetings[0][1])

    for start, end in sorted_meetings[1:]:
        # 가장 빨리 끝나는 회의실이 현재 회의 시작 전에 끝나면 재활용
        if rooms[0] <= start:
            heapq.heappop(rooms)  # 기존 회의실 제거

        # 새 회의의 종료 시간을 힙에 추가 (재활용이든 신규든)
        heapq.heappush(rooms, end)

    # 힙의 크기 = 필요한 회의실 수
    return len(rooms)


# 예제 실행
meetings = [(0, 30), (5, 10), (15, 20)]
print(f"최소 회의실 수: {min_meeting_rooms(meetings)}")  # 2

meetings2 = [(1, 3), (2, 4), (3, 5)]
print(f"최소 회의실 수: {min_meeting_rooms(meetings2)}")  # 2

단계별 동작 시각화 (최소 회의실)

입력: [(0, 30), (5, 10), (15, 20)]

단계 현재 회의 힙 상태 (종료 시간들) 재활용? 회의실 수
1 (0, 30) [30] 1
2 (5, 10) [10, 30] 불가 (30 > 5) 2
3 (15, 20) [20, 30] 가능 (10 ≤ 15) 2

결과: 최소 2개 회의실 필요

복잡도 분석

  • 시간 복잡도: O(nlogn)O(n \log n) — 정렬 O(nlogn)O(n \log n) + 힙 연산 O(nlogn)O(n \log n)
  • 공간 복잡도: O(n)O(n) — 최악의 경우 모든 회의가 겹치면 힙에 nn

대안: 스위핑으로 풀기

def min_meeting_rooms_sweep(meetings):
    """
    스위핑 기법으로 최소 회의실 수를 구한다.
    시작 시점에 +1, 종료 시점에 -1을 하여 동시 진행 최대값을 구한다.
    """
    events = []
    for start, end in meetings:
        events.append((start, 1))   # 회의 시작: +1
        events.append((end, -1))    # 회의 종료: -1

    # 시간순 정렬 (같은 시간이면 종료(-1)를 먼저 처리)
    # -1 < 1이므로 자동으로 종료가 먼저 정렬됨
    events.sort()

    max_rooms = 0
    current_rooms = 0

    for time, delta in events:
        current_rooms += delta
        max_rooms = max(max_rooms, current_rooms)

    return max_rooms


# 검증
print(min_meeting_rooms_sweep([(0, 30), (5, 10), (15, 20)]))  # 2

: 스위핑 방식은 구현이 간결하고, 같은 시간에 시작/종료가 겹치는 경우 정렬 기준(-1 < 1)이 자동으로 종료를 먼저 처리하여 정확한 결과를 얻는다.


유형 3: 분할 가능한 배낭 문제 (Fractional Knapsack)

문제 설명

배낭의 용량이 WW이고, nn개의 물건이 있다. 각 물건은 무게 wiw_i와 가치 viv_i를 가진다. 물건을 쪼개서 넣을 수 있을 때, 배낭에 넣을 수 있는 최대 가치를 구하라.

0-1 배낭 vs 분할 가능 배낭

구분 0-1 배낭 분할 가능 배낭
물건 분할 불가 (넣거나 안 넣거나) 가능 (일부만 넣기 가능)
풀이 방식 DP 그리디
시간 복잡도 O(nW)O(nW) O(nlogn)O(n \log n)

핵심 아이디어

단위 무게당 가치(vi/wiv_i / w_i)가 높은 물건부터 넣어라. 분할이 가능하므로 가성비가 좋은 물건을 최대한 채우는 것이 최적이다.

Python 풀이

def fractional_knapsack(capacity, items):
    """
    분할 가능 배낭 문제를 그리디로 풀기.
    capacity: 배낭 용량
    items: [(무게, 가치), ...]
    반환: (최대 가치, 선택 내역)
    """
    # 1단계: 단위 무게당 가치 기준 내림차순 정렬
    sorted_items = sorted(items, key=lambda x: x[1] / x[0], reverse=True)

    total_value = 0
    remaining = capacity
    selection = []  # (물건 인덱스, 비율)

    for weight, value in sorted_items:
        if remaining <= 0:
            break

        if weight <= remaining:
            # 물건 전체를 넣을 수 있는 경우
            total_value += value
            remaining -= weight
            selection.append((weight, value, 1.0))  # 100% 선택
        else:
            # 남은 용량만큼만 쪼개서 넣기
            fraction = remaining / weight
            total_value += value * fraction
            selection.append((weight, value, fraction))
            remaining = 0

    return total_value, selection


# 예제 실행
items = [(10, 60), (20, 100), (30, 120)]  # (무게, 가치)
capacity = 50

max_value, selection = fractional_knapsack(capacity, items)
print(f"최대 가치: {max_value}")
print("선택 내역:")
for w, v, frac in selection:
    print(f"  무게 {w}, 가치 {v}{frac*100:.0f}% 선택 (획득 가치: {v*frac:.1f})")

단계별 동작 시각화

물건 정보 (단위 가치 내림차순 정렬 후):

물건 무게 ww 가치 vv 단위 가치 v/wv/w
A 10 60 6.0
B 20 100 5.0
C 30 120 4.0

배낭 용량: W=50W = 50

단계 물건 남은 용량 선택 비율 획득 가치 누적 가치
1 A(10, 60) 50 → 40 100% 60 60
2 B(20, 100) 40 → 20 100% 100 160
3 C(30, 120) 20 → 0 66.7% 80 240

결과: 최대 가치 = 240

복잡도 분석

  • 시간 복잡도: O(nlogn)O(n \log n) — 정렬이 지배적
  • 공간 복잡도: O(n)O(n) — 정렬된 배열

유형 4: 최소 동전 교환 (Coin Change – Greedy)

문제 설명

주어진 동전 종류로 금액 NN원을 만들 때, 최소 동전 개수를 구하라.

주의: 그리디가 항상 최적해를 보장하는 건 아니다! 동전 단위가 서로의 배수일 때만 그리디가 정당하다.

그리디가 되는 경우 vs 안 되는 경우

동전 종류 그리디 적용 이유
{1, 5, 10, 50, 100, 500} 가능 각 동전이 하위 동전의 배수
{1, 5, 10, 25} (미국 동전) 가능 배수 관계 성립
{1, 3, 4} 불가 6원: 그리디→4+1+1(3개), 최적→3+3(2개)

Python 풀이 (한국 화폐)

def min_coins_greedy(amount, coins):
    """
    그리디로 최소 동전 개수를 구한다.
    동전이 서로의 배수일 때만 정당한 결과를 보장한다.
    """
    # 큰 단위부터 정렬
    coins_desc = sorted(coins, reverse=True)

    total_count = 0
    breakdown = []  # 각 동전별 사용 개수
    remaining = amount

    for coin in coins_desc:
        if remaining <= 0:
            break
        count = remaining // coin  # 해당 동전을 최대한 사용
        if count > 0:
            breakdown.append((coin, count))
            total_count += count
            remaining -= coin * count

    return total_count, breakdown


# 예제: 백준 11047번 (동전 0)
coins = [1, 5, 10, 50, 100, 500, 1000, 5000, 10000, 50000]
amount = 4790

count, breakdown = min_coins_greedy(amount, coins)
print(f"{amount}원을 만드는 최소 동전 수: {count}개")
for coin, cnt in breakdown:
    print(f"  {coin}원 × {cnt}개")

출력 결과

4790원을 만드는 최소 동전 : 12
  1000 × 4
  500 × 1
  100 × 2
  50 × 1
  10 × 4

계산 과정:
– 1000 × 4 = 4000, 남은 790
– 500 × 1 = 500, 남은 290
– 100 × 2 = 200, 남은 90
– 50 × 1 = 50, 남은 40
– 10 × 4 = 40, 남은 0
– 총 4 + 1 + 2 + 1 + 4 = 12개

복잡도 분석

  • 시간 복잡도: O(k)O(k)kk는 동전 종류 수 (이미 정렬되어 있다면)
  • 공간 복잡도: O(k)O(k)

엣지 케이스

  1. 금액이 0인 경우: 동전 0개
  2. 가장 작은 동전이 1이 아닌 경우: 정확히 만들 수 없을 수 있다
  3. 그리디가 실패하는 동전 조합: 반드시 DP로 풀어야 한다
# 그리디가 실패하는 예시
def min_coins_dp(amount, coins):
    """DP로 최소 동전 개수를 구한다 (항상 정확)."""
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1

    return dp[amount] if dp[amount] != float('inf') else -1


# {1, 3, 4} 동전으로 6원 만들기
print(f"그리디: {min_coins_greedy(6, [1, 3, 4])[0]}개")  # 3개 (4+1+1)
print(f"DP:     {min_coins_dp(6, [1, 3, 4])}개")          # 2개 (3+3)

유형 5: 구간 스케줄링과 구간 겹침

5-1. 겹치지 않는 최대 구간 수

활동 선택 문제와 본질적으로 동일하다. 코딩테스트에서는 다양한 변형으로 출제된다.

5-2. 최소 구간 제거 (겹치는 구간 없애기)

문제: nn개의 구간 중 최소 개수를 제거하여 나머지가 모두 겹치지 않게 만들어라.

핵심 공식: 제거할 구간 수 = 전체 구간 수 – 겹치지 않는 최대 구간 수

제거 수=nmaxNonOverlapping(n)\text{제거 수} = n – \text{maxNonOverlapping}(n)

def erase_overlap_intervals(intervals):
    """
    겹치는 구간을 최소한으로 제거하여 나머지가 겹치지 않게 만든다.
    LeetCode 435. Non-overlapping Intervals
    """
    if not intervals:
        return 0

    # 종료 시간 기준 정렬
    intervals.sort(key=lambda x: x[1])

    # 겹치지 않는 최대 구간 수 구하기 (활동 선택)
    non_overlap = 1
    last_end = intervals[0][1]

    for start, end in intervals[1:]:
        if start >= last_end:
            non_overlap += 1
            last_end = end

    # 제거해야 할 구간 수
    return len(intervals) - non_overlap


# 예제
print(erase_overlap_intervals([[1,2],[2,3],[3,4],[1,3]]))  # 1
print(erase_overlap_intervals([[1,2],[1,2],[1,2]]))        # 2

5-3. 구간을 커버하는 최소 점 수 (Minimum Arrows to Burst Balloons)

문제: nn개의 풍선(구간)이 있을 때, 모든 풍선을 터뜨리는 최소 화살 수를 구하라. 하나의 화살은 xx 좌표에서 수직으로 발사되어, 그 xx를 포함하는 모든 풍선을 터뜨린다.

def find_min_arrows(points):
    """
    모든 풍선을 터뜨리는 최소 화살 수.
    LeetCode 452. Minimum Number of Arrows to Burst Balloons
    """
    if not points:
        return 0

    # 종료 좌표 기준 정렬
    points.sort(key=lambda x: x[1])

    arrows = 1
    arrow_pos = points[0][1]  # 첫 화살은 첫 풍선의 끝 지점에 쏜다

    for start, end in points[1:]:
        if start > arrow_pos:  # 현재 화살로 못 터뜨리면
            arrows += 1
            arrow_pos = end  # 새 화살 발사

    return arrows


# 예제
print(find_min_arrows([[10,16],[2,8],[1,6],[7,12]]))  # 2
print(find_min_arrows([[1,2],[3,4],[5,6],[7,8]]))      # 4

유형 6: 문자열 그리디

6-1. 가장 큰 수 만들기

문제: 0 이상의 정수가 담긴 배열을 조합하여 가장 큰 수를 만들어라.

핵심 아이디어: 두 수 aa, bb를 비교할 때, 문자열 연결 a+bb+a를 비교하여 더 큰 쪽이 앞에 오도록 정렬한다.

from functools import cmp_to_key

def largest_number(nums):
    """
    숫자 배열을 조합하여 가장 큰 수를 만든다.
    프로그래머스 "가장 큰 수" / LeetCode 179
    """
    # 커스텀 비교: "ab" vs "ba"로 비교
    def compare(x, y):
        if x + y > y + x:
            return -1  # x가 앞에
        elif x + y < y + x:
            return 1   # y가 앞에
        return 0

    # 문자열로 변환 후 커스텀 정렬
    str_nums = [str(n) for n in nums]
    str_nums.sort(key=cmp_to_key(compare))

    # 앞이 모두 0인 경우 처리 (예: [0, 0, 0])
    result = ''.join(str_nums)
    return '0' if result[0] == '0' else result


# 예제
print(largest_number([3, 30, 34, 5, 9]))   # "9534330"
print(largest_number([10, 2]))              # "210"
print(largest_number([0, 0, 0]))            # "0"

비교 과정 시각화

[3, 30, 34, 5, 9]의 정렬 과정:

비교 쌍 a+b b+a 결과
“9” vs “5” “95” “59” 9 > 5 → 9가 앞
“5” vs “34” “534” “345” 534 > 345 → 5가 앞
“34” vs “3” “343” “334” 343 > 334 → 34가 앞
“3” vs “30” “330” “303” 330 > 303 → 3이 앞

정렬 결과: [9, 5, 34, 3, 30]“9534330”

6-2. 큰 수 만들기 (숫자 제거)

문제: 숫자 문자열에서 kk개의 숫자를 제거하여 가능한 가장 큰 수를 만들어라.

핵심 아이디어: 스택을 사용하여 현재 숫자보다 작은 앞의 숫자들을 제거한다. 왼쪽에서 오른쪽으로 순회하며, 스택의 top이 현재 숫자보다 작으면 pop하고(제거 횟수가 남아있을 때), 현재 숫자를 push한다.

def remove_k_digits_for_max(number, k):
    """
    number에서 k개의 숫자를 제거하여 가장 큰 수를 만든다.
    프로그래머스 "큰 수 만들기"

    동작 원리:
    - 스택에 숫자를 하나씩 넣으면서, 현재 숫자가 스택 top보다 크면
      스택에서 작은 숫자를 제거 (제거 횟수 k가 남아있을 때)
    - 이렇게 하면 왼쪽부터 큰 숫자가 오도록 배치됨
    """
    stack = []

    for digit in number:
        # 스택 top보다 현재 숫자가 크면 스택에서 제거 (제거 횟수 남아있을 때)
        while stack and k > 0 and stack[-1] < digit:
            stack.pop()
            k -= 1
        stack.append(digit)

    # 아직 제거 횟수가 남아있으면 뒤에서부터 제거
    if k > 0:
        stack = stack[:-k]

    return ''.join(stack)


# 예제
print(remove_k_digits_for_max("1924", 2))    # "94"
print(remove_k_digits_for_max("1231234", 3)) # "3234"
print(remove_k_digits_for_max("4177252841", 4))  # "775841"

복잡도 분석

  • 시간 복잡도: O(n)O(n) — 각 숫자는 스택에 한 번 push, 최대 한 번 pop
  • 공간 복잡도: O(n)O(n) — 스택

유형 7: 허프만 코딩 (Huffman Coding)

문제 설명

문자열의 각 문자에 가변 길이 이진 코드를 할당하여 전체 인코딩 길이를 최소화하라. 빈도가 높은 문자에 짧은 코드를, 빈도가 낮은 문자에 긴 코드를 할당한다.

핵심 아이디어

빈도가 가장 낮은 두 노드를 반복적으로 합친다. 이것이 최적 접두사 코드(prefix code)를 만드는 그리디 전략이다.

인코딩의 총 비용은:

C=i=1nfidiC = \sum_{i=1}^{n} f_i \cdot d_i

  • fif_i: 문자 ii의 빈도
  • did_i: 문자 ii의 코드 길이(트리에서의 깊이)
  • CC: 최소화할 총 비트 수

Python 풀이

import heapq
from collections import Counter

class HuffmanNode:
    def __init__(self, char, freq):
        self.char = char
        self.freq = freq
        self.left = None
        self.right = None

    # 힙 비교를 위한 연산자 정의
    def __lt__(self, other):
        return self.freq < other.freq


def build_huffman_tree(text):
    """허프만 트리를 구축한다."""
    # 1단계: 각 문자의 빈도 계산
    freq = Counter(text)

    # 2단계: 최소 힙에 모든 문자를 넣기
    heap = [HuffmanNode(char, f) for char, f in freq.items()]
    heapq.heapify(heap)

    # 3단계: 가장 빈도 낮은 두 노드를 합치기 반복
    while len(heap) > 1:
        left = heapq.heappop(heap)   # 빈도 가장 낮은 노드
        right = heapq.heappop(heap)  # 두 번째로 낮은 노드

        # 합친 노드 생성 (내부 노드는 char=None)
        merged = HuffmanNode(None, left.freq + right.freq)
        merged.left = left
        merged.right = right

        heapq.heappush(heap, merged)

    return heap[0]  # 루트 노드


def get_codes(node, prefix="", codes=None):
    """허프만 트리를 순회하여 각 문자의 코드를 구한다."""
    if codes is None:
        codes = {}

    if node.char is not None:  # 리프 노드
        codes[node.char] = prefix if prefix else "0"  # 문자가 1개일 때
        return codes

    get_codes(node.left, prefix + "0", codes)   # 왼쪽: 0
    get_codes(node.right, prefix + "1", codes)  # 오른쪽: 1

    return codes


# 예제 실행
text = "aaaaabbbbbbbbbccccccccccccdddddddddddddeeeeeeeeeeeeeeeefffffffffffffffffffffffffffff"
root = build_huffman_tree(text)
codes = get_codes(root)

freq = Counter(text)
print("문자별 허프만 코드:")
print(f"{'문자':<6}{'빈도':<8}{'코드':<12}{'비트수'}")
print("-" * 36)
for char, code in sorted(codes.items(), key=lambda x: len(x[1])):
    print(f"{char:<6}{freq[char]:<8}{code:<12}{len(code)}")

# 인코딩 길이 비교
original_bits = len(text) * 8  # ASCII 8비트
huffman_bits = sum(freq[c] * len(codes[c]) for c in freq)
print(f"\n원본: {original_bits} bits")
print(f"허프만: {huffman_bits} bits")
print(f"압축률: {huffman_bits/original_bits*100:.1f}%")

복잡도 분석

  • 시간 복잡도: O(nlogn)O(n \log n)nn개 문자를 힙에 넣고 합치기
  • 공간 복잡도: O(n)O(n) — 트리 노드

유형 8: 작업 스케줄링 (Job Scheduling)

문제: 마감 시한이 있는 작업 이익 최대화

nn개의 작업이 있고, 각 작업은 마감 시한 did_i와 이익 pip_i를 가진다. 한 번에 하나의 작업만 수행할 수 있고, 각 작업은 단위 시간이 걸린다. 이익의 합을 최대화하라.

핵심 아이디어

이익이 큰 작업부터 선택하되, 마감 시한에 가장 가까운 빈 슬롯에 배정한다.

def job_scheduling(jobs):
    """
    마감 시한이 있는 작업 스케줄링.
    jobs: [(작업ID, 마감시한, 이익), ...]
    반환: (최대 이익, 스케줄)
    """
    # 1단계: 이익 기준 내림차순 정렬
    sorted_jobs = sorted(jobs, key=lambda x: x[2], reverse=True)

    # 최대 마감 시한 구하기
    max_deadline = max(job[1] for job in jobs)

    # 시간 슬롯 배열 (1-indexed, 0은 미사용)
    slots = [None] * (max_deadline + 1)

    total_profit = 0
    schedule = []

    # 2단계: 이익 큰 작업부터 가능한 슬롯에 배정
    for job_id, deadline, profit in sorted_jobs:
        # 마감 시한부터 역순으로 빈 슬롯 탐색
        for t in range(min(deadline, max_deadline), 0, -1):
            if slots[t] is None:
                slots[t] = job_id
                total_profit += profit
                schedule.append((t, job_id, profit))
                break

    schedule.sort()  # 시간 순 정렬
    return total_profit, schedule


# 예제
jobs = [
    ('A', 2, 100),
    ('B', 1, 19),
    ('C', 2, 27),
    ('D', 1, 25),
    ('E', 3, 15),
]

profit, schedule = job_scheduling(jobs)
print(f"최대 이익: {profit}")
print("스케줄:")
for t, job_id, p in schedule:
    print(f"  시간 {t}: 작업 {job_id} (이익 {p})")

출력 결과

최대 이익: 142
스케줄:
  시간 1: 작업 C (이익 27)
  시간 2: 작업 A (이익 100)
  시간 3: 작업 E (이익 15)

복잡도 분석

  • 시간 복잡도: O(n2)O(n^2) — 각 작업마다 슬롯 탐색 (Union-Find로 O(nα(n))O(n \alpha(n))까지 최적화 가능)
  • 공간 복잡도: O(dmax)O(d_{max}) — 슬롯 배열

그리디 문제 풀이 전략 총정리

그리디 알고리즘 적용 판별 체크리스트

  1. 정렬 기준이 명확한가? — 종료 시간, 단위 가치, 이익 등
  2. 한 번 선택하면 되돌릴 필요가 없는가? — 교환 논증으로 검증
  3. 탐욕적 선택이 나머지 부분 문제에 영향을 주지 않는가?
  4. 반례가 있는가? — 간단한 예제로 빠르게 검증

코딩테스트 빈출 패턴

패턴 정렬 기준 대표 문제
종료 시간 정렬 종료 시간 오름차순 활동 선택, 회의실 배정
시작 시간 정렬 + 힙 시작 시간 오름차순 최소 회의실 수
단위 가치 정렬 가치/무게 내림차순 분할 가능 배낭
큰 단위 우선 단위 내림차순 동전 교환, 거스름돈
빈도 기반 합치기 빈도 오름차순 (힙) 허프만 코딩
이익 내림차순 이익 내림차순 작업 스케줄링
커스텀 비교 문제 특성에 따라 가장 큰 수 만들기

자주 등장하는 실수 모음

실수 설명 해결법
정렬 기준 오류 시작 시간으로 정렬해야 할 것을 종료 시간으로 문제 요구사항 재확인
동점 처리 누락 종료 시간 같을 때 시작 시간 정렬 누락 2차 정렬 키 추가
그리디 정당성 미검증 반례가 존재하는 문제에 그리디 적용 작은 예제로 반례 확인
경계 조건 (>= vs >) 구간 끝점 포함 여부 혼동 문제에서 닫힌/열린 구간 확인
빈 입력 처리 배열이 비어있을 때 런타임 에러 초기 예외 처리 추가

연습 문제 추천

난이도별 추천 문제

난이도 플랫폼 문제 유형
쉬움 백준 11047 동전 0 동전 교환
쉬움 백준 1931 회의실 배정 활동 선택
쉬움 백준 11399 ATM 정렬 그리디
보통 백준 1744 수 묶기 정렬 + 조건 분기
보통 프로그래머스 큰 수 만들기 스택 그리디
보통 LeetCode 435 Non-overlapping Intervals 구간 제거
보통 LeetCode 452 Minimum Arrows 구간 커버
어려움 백준 1202 보석 도둑 정렬 + 힙
어려움 LeetCode 621 Task Scheduler 빈도 기반 스케줄링
어려움 백준 2109 순회강연 마감 시한 스케줄링

마무리

그리디 알고리즘의 핵심을 정리하면 다음과 같다.

  1. 매 순간 최선의 선택을 하되, 그 선택이 전체 최적해로 이어지는지 반드시 검증하라.
  2. 대부분의 그리디 문제는 정렬이 핵심이다. 어떤 기준으로 정렬할지가 풀이의 80%를 차지한다.
  3. 교환 논증(Exchange Argument)으로 그리디의 정당성을 증명하는 습관을 들여라.
  4. 그리디가 안 되면 DP나 완전탐색으로 전환하라. 동전 교환 문제처럼 비슷해 보여도 그리디가 실패하는 경우가 있다.
  5. 코딩테스트에서 시간 복잡도 O(nlogn)O(n \log n)이면 대부분의 제한 조건(n105106n \leq 10^5 \sim 10^6)을 통과한다.
  6. 정렬 → 순회 → 조건 판단의 3단계 패턴을 체화하면 대부분의 그리디 문제를 빠르게 풀 수 있다.

그리디는 “증명이 어렵지만 구현은 쉬운” 알고리즘이다. 많은 문제를 풀어보면서 어떤 상황에서 그리디가 통하고 어떤 상황에서 실패하는지 감을 익히는 것이 가장 중요하다.

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 473 | TOTAL 2,696