구간 스케줄링과 병합
종료 시간을 기준으로 정렬해 회의실 문제와 겹치지 않는 구간 문제를 해결하고, 시작 시간을 기준으로 정렬해 구간을 병합합니다.
구간 스케줄링과 병합은(는) CoddyKit의 무료 Coding Interview Prep 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Coding Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
구간 문제 개요
구간 문제는 스케줄링, 달력 관리, 자원 할당 면접에서 끊임없이 등장합니다. 핵심 패턴은 다음과 같습니다. 겹치는 구간 병합, 필요한 최소 회의실 개수 세기, 겹치지 않는 집합의 최대 크기 찾기, 새 구간 삽입. 대부분의 구간 문제는 같은 단계에서 시작합니다. 즉, 문제에 따라 구간을 시작 시간(time) 또는 종료 시간 기준으로 sort합니다. 올바른 정렬 기준을 선택하는 일이 가장 어려운 경우가 많습니다.
# Intervals: each = [start, end] (inclusive or exclusive by problem)
# Example:
intervals = [[1,3],[2,6],[8,10],[15,18]]
# Sorted by start (already sorted here)
# Visually:
# [1,3] |-|
# [2,6] |---|
# [8,10] |--|
# [15,18] |---|
print('Intervals ready for analysis')겹치는 구간 병합
구간 병합(LeetCode 56): 구간 목록이 주어지면 겹치는 구간을 모두 병합합니다. 알고리즘은 다음과 같습니다. 시작 시간(time) 기준으로 sort합니다. 정렬된 목록을 순회하면서 현재 구간이 마지막으로 병합된 구간과 겹치면(현재 구간의 시작 ≤ 마지막 병합 구간의 종료 지점(end)) 양쪽 종료 지점 중 최댓값으로 마지막 병합 구간의 종료 지점을 확장합니다. 겹치지 않으면 현재 구간을 새로운 병합 구간으로 append합니다. 시간 복잡도는 정렬에 O(n log n), 병합에 O(n)입니다.
def merge_intervals(intervals):
intervals.sort(key=lambda x: x[0]) # sort by start
merged = [intervals[0]]
for start, end in intervals[1:]:
last_end = merged[-1][1]
if start <= last_end:
# Overlapping: extend the last interval
merged[-1][1] = max(last_end, end)
else:
# Non-overlapping: add as new interval
merged.append([start, end])
return merged
print(merge_intervals([[1,3],[2,6],[8,10],[15,18]]))
# [[1,6],[8,10],[15,18]]
print(merge_intervals([[1,4],[4,5]]))
# [[1,5]] (touching intervals merge)구간 삽입
구간 삽입(LeetCode 57): 정렬되어 있고 서로 겹치지 않는 목록이 주어지면 새 구간을 삽입한 뒤 다시 병합합니다. 다음 세 단계로 순회합니다. (1) 새 구간이 시작하기 전에 종료되는 모든 구간을 추가합니다. (2) 새 구간과 겹치는 모든 구간을 병합하여 새 구간의 경계를 확장합니다. (3) 남은 모든 구간을 추가합니다. 이 문제에서는 이미 정렬이 끝났으므로 O(n log n) 정렬 이후 O(n)에 한 번 순회하면 됩니다.
def insert_interval(intervals, new_interval):
result = []
i = 0
n = len(intervals)
# Phase 1: intervals before new_interval
while i < n and intervals[i][1] < new_interval[0]:
result.append(intervals[i])
i += 1
# Phase 2: merge overlapping intervals
while i < n and intervals[i][0] <= new_interval[1]:
new_interval[0] = min(new_interval[0], intervals[i][0])
new_interval[1] = max(new_interval[1], intervals[i][1])
i += 1
result.append(new_interval)
# Phase 3: remaining intervals
while i < n:
result.append(intervals[i])
i += 1
return result
print(insert_interval([[1,3],[6,9]], [2,5])) # [[1,5],[6,9]]
print(insert_interval([[1,2],[3,5],[6,7],[8,10],[12,16]], [4,8]))
# [[1,2],[3,10],[12,16]]회의실 I: 모두 참석할 수 있을까요?
회의실 I(LeetCode 252): 회의 시간(time) 구간이 주어질 때 한 사람이 모든 회의에 참석할 수 있는지 판별합니다. 시작 시간 기준으로 정렬합니다. 어떤 회의라도 이전 회의가 끝나기 전에 시작하면 두 회의가 겹칩니다. 전체 시간 복잡도는 O(n log n)인 가장 간단한 구간 확인 문제입니다. 핵심은 정렬한 뒤 연속된 쌍만 비교하면 된다는 점입니다.
def can_attend_meetings(intervals):
intervals.sort(key=lambda x: x[0])
for i in range(1, len(intervals)):
# Current meeting starts before previous ends?
if intervals[i][0] < intervals[i-1][1]:
return False
return True
print(can_attend_meetings([[0,30],[5,10],[15,20]])) # False (0,30 overlaps 5,10)
print(can_attend_meetings([[7,10],[2,4]])) # True (4 < 7, no overlap)회의실 II: 최소 회의실 수
회의실 II(LeetCode 253): 모든 회의를 동시에 진행하는 데 필요한 최소 회의실 수를 구합니다. 가장 빨리 끝나는 회의실을 추적하기 위해 최소 힙을 사용합니다. 회의를 시작 시간 기준으로 정렬합니다. 각 새 회의에 대해 가장 빨리 끝나는 회의실의 종료 시간(time) 이후에 시작하면 해당 회의실을 재사용하고(pop한 뒤 다시 삽입), 그렇지 않으면 새 회의실을 엽니다. 마지막 end에서 힙의 크기가 필요한 회의실 수가 됩니다.
import heapq
def min_meeting_rooms(intervals):
if not intervals: return 0
intervals.sort(key=lambda x: x[0]) # sort by start
heap = [] # min-heap of end times
for start, end in intervals:
if heap and heap[0] <= start:
heapq.heapreplace(heap, end) # reuse earliest-ending room
else:
heapq.heappush(heap, end) # open a new room
return len(heap)
print(min_meeting_rooms([[0,30],[5,10],[15,20]])) # 2
print(min_meeting_rooms([[7,10],[2,4]])) # 1
print(min_meeting_rooms([[9,10],[4,9],[4,17]])) # 2회의실 수를 세는 스위프 라인 대안
또 다른 O(n log n) 접근법은 스위프 라인입니다. 각 구간의 시작 이벤트(+1)와 종료 이벤트(end, -1)를 만듭니다. 모든 이벤트를 시간(time) 기준으로 정렬합니다(동률일 때 비포함 구간을 원한다면 종료 이벤트를 시작 이벤트보다 먼저 둡니다). 왼쪽에서 오른쪽으로 훑으면서 현재 진행 중인 회의 수를 계속 셉니다. 최댓값이 필요한 최소 회의실 수입니다. 이 방법은 일부 사람에게 더 직관적이며 구간에 대한 다른 개수 세기 문제에도 일반화할 수 있습니다.
def min_rooms_sweep(intervals):
events = []
for start, end in intervals:
events.append((start, 1)) # meeting starts
events.append((end, -1)) # meeting ends
# Sort: same time → end (-1) before start (1) if exclusive
events.sort(key=lambda x: (x[0], x[1]))
max_rooms = current = 0
for _, delta in events:
current += delta
max_rooms = max(max_rooms, current)
return max_rooms
print(min_rooms_sweep([[0,30],[5,10],[15,20]])) # 2
print(min_rooms_sweep([[1,5],[2,6],[3,7]])) # 3 (all overlap at t=3)겹치지 않는 구간: 최대 선택
겹치지 않는 구간(LeetCode 435): 나머지 구간이 서로 겹치지 않도록 제거해야 하는 최소 구간 수를 구합니다. 이는 겹치지 않는 구간의 최대 개수(활동 선택)를 구한 뒤 나머지를 제거 횟수로 반환하는 것과 같습니다. 종료 시간(time) 기준으로 sort합니다. 종료가 가장 빠른 구간을 탐욕적으로 유지하면 이후 구간을 위한 공간이 최대화됩니다. 다음 구간이 겹치면 해당 구간을 discard하고 제거 횟수를 셉니다.
def erase_overlap_intervals(intervals):
if not intervals: return 0
intervals.sort(key=lambda x: x[1]) # sort by END time
removals = 0
last_end = float('-inf')
for start, end in intervals:
if start >= last_end:
last_end = end # keep this interval
else:
removals += 1 # remove this interval (it overlaps)
return removals
print(erase_overlap_intervals([[1,2],[2,3],[3,4],[1,3]])) # 1 (remove [1,3])
print(erase_overlap_intervals([[1,2],[1,2],[1,2]])) # 2
print(erase_overlap_intervals([[1,2],[2,3]])) # 0 (no overlap)시작 시간이 아니라 종료 시간으로 정렬하는 이유
활동 선택(겹치지 않는 최대 집합)에서는 종료 시간을 기준으로 정렬하는 것이 증명된 최적 방법입니다. 직관적으로, 일찍 끝나는 활동일수록 이후 활동을 위한 공간을 더 많이 남깁니다. 시작 시간을 기준으로 정렬하면 너무 일찍 시작하지만 매우 긴 활동을 선택하여, 그 뒤에 오는 더 짧은 활동을 많이 막을 수 있습니다. 교환 논증을 살펴보겠습니다. 최적해가 가장 먼저 끝나는 G 대신 활동 A를 선택했다고 합시다. A를 G로 바꾸어도 됩니다. G는 A보다 늦게 끝나지 않으므로, A와 충돌하지 않던 활동과 충돌하지 않습니다.
# Counterexample for sorting by START time:
# [[1,10],[2,3],[4,5]] — sorted by start: [1,10],[2,3],[4,5]
# Sort-by-start greedy keeps [1,10], can't add [2,3] or [4,5] (all overlap [1,10])
# Selects: 1 interval
# Sort-by-end greedy:
# [[2,3],[4,5],[1,10]] — sorted by end
# Keep [2,3] (end=3), then [4,5] (start=4 >= 3, keep), then [1,10] (start=1 < 5, skip)
# Selects: 2 intervals — OPTIMAL
intervals = [[1,10],[2,3],[4,5]]
intervals.sort(key=lambda x: x[1])
last_end = float('-inf')
count = 0
for s, e in intervals:
if s >= last_end:
count += 1; last_end = e
print('Max non-overlapping:', count) # 2구간 목록의 교집합
구간 목록의 교집합(LeetCode 986): 정렬된 두 구간 목록에서 교차하는 모든 쌍을 찾습니다. 투 포인터 접근법을 사용합니다. 각 단계에서 현재 쌍의 교집합을 계산합니다(시작점의 최댓값과 끝점의 최솟값). 시작점 ≤ 끝점이면 교집합이 유효합니다. 그런 다음 먼저 끝나는 구간의 포인터를 이동합니다. 시간 복잡도는 O(m+n)입니다.
def interval_intersection(A, B):
result = []
i = j = 0
while i < len(A) and j < len(B):
# Intersection boundaries
lo = max(A[i][0], B[j][0])
hi = min(A[i][1], B[j][1])
if lo <= hi:
result.append([lo, hi]) # valid intersection
# Advance pointer of interval that ends first
if A[i][1] < B[j][1]:
i += 1
else:
j += 1
return result
A = [[0,2],[5,10],[13,23],[24,25]]
B = [[1,5],[8,12],[15,24],[25,26]]
print(interval_intersection(A, B))
# [[1,2],[5,5],[8,10],[15,23],[24,24],[25,25]]구간 분할
구간 분할(LeetCode 763): 각 문자가 최대 하나의 부분에만 나타나도록 문자열을 가능한 한 많은 부분으로 나눕니다. 탐욕법으로 각 문자의 마지막 등장 위치를 찾습니다. max_end를 유지하면서 문자열을 순회합니다. i == max_end가 되면 현재 부분이 완성된 것이므로 길이를 기록하고 새 부분을 시작합니다. 이는 겉보기와 달리 구간 병합 문제입니다.
def partition_labels(s):
last = {c: i for i, c in enumerate(s)} # last occurrence of each char
partitions = []
start = max_end = 0
for i, c in enumerate(s):
max_end = max(max_end, last[c])
if i == max_end: # partition complete
partitions.append(max_end - start + 1)
start = i + 1
return partitions
print(partition_labels('ababcbacadefegdehijhklij'))
# [9, 7, 8] — parts 'ababcbaca', 'defegde', 'hijhklij'구간 문제 요약
구간 문제에서 다음 네 가지 패턴을 익혀 두세요. (1) 병합: 시작점을 기준으로 정렬하고, 겹치면 마지막 구간을 확장합니다. (2) 회의실 수 세기: 시작점을 기준으로 정렬하고 끝 시간의 최소 힙을 사용합니다. (3) 겹치지 않는 구간 최대화: 종료 시간을 기준으로 정렬하고 탐욕적으로 선택합니다. (4) 삽입: 세 단계로 선형 순회합니다. 정렬 기준이 중요합니다. 병합에는 시작점을 사용하고, 최대 선택에는 종료 시간을 사용합니다. 시간 복잡도는 항상 O(n log n)이며 정렬이 대부분을 차지합니다. 병합과 순회는 O(n)입니다.
# Quick reference:
# Merge intervals: sort by start, extend if overlap
# Insert interval: three-phase linear scan
# Meeting rooms (can?): sort by start, check consecutive overlap
# Meeting rooms (min?): sort by start, min-heap of end times / sweep
# Max non-overlapping: sort by END, greedy keep
# Min removals: n - max_non_overlapping
# Interval intersection: two pointers on sorted lists
print('Pattern: sort key is the decisive choice')
print('Merge → sort by start')
print('Activity selection → sort by end')
print('Room count → sort by start + heap of ends')빠른 확인
이 단원에서 다룬 자료 구조 및 알고리즘 — 코딩 면접 준비 개념에 대한 이해도를 확인해 보세요.
단원 요약
이 단원에서는 다음을 배웠습니다. 시작점을 기준으로 정렬한 뒤 겹침이 발생하면 마지막 구간을 확장하여 구간을 병합하는 방법, 시작점 정렬과 끝 시간의 최소 힙을 사용하여 최소 회의실 수를 구하고, 가장 먼저 끝나는 회의실이 비면 재사용하는 방법, 그리고 종료 시간 기준의 탐욕적 선택으로 겹치지 않는 구간을 최대로 선택하는 방법입니다. 다음으로는 탐욕적 범위 확장으로 해결하는 도달 가능성 및 최소 점프 문제인 점프 게임 I과 II를 다룹니다.
자주 묻는 질문
“구간 스케줄링과 병합” 강의는 무료인가요?
네 — “구간 스케줄링과 병합” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Coding Interview Prep 강의 전체를 잠금 해제할 수 있습니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“구간 스케줄링과 병합”에서 뭘 배우나요?
종료 시간을 기준으로 정렬해 회의실 문제와 겹치지 않는 구간 문제를 해결하고, 시작 시간을 기준으로 정렬해 구간을 병합합니다. 브라우저에서 직접 실행하는 실습 코드로 Coding Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Coding Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Coding Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.
“구간 스케줄링과 병합” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Coding Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Coding Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 그리디와 DP: 각각 언제 사용할까
- 구간 스케줄링과 병합
- 점프 게임 I과 II
- 작업 스케줄러와 주유소