PS를 위한 파이썬 정리

코딩테스트에서 자주 사용했던 파이썬 문법, 라이브러리, 알고리즘 템플릿 등 정리

2026. 04. 2750 min

1. 입출력

기본 입력

input()은 한 줄을 문자열로 읽고, split()은 공백 기준으로 나눈다. map()으로 형변환하면 정수 리스트를 만들 수 있다.

# 한 줄 읽기
s = input()
 
# 공백으로 구분된 입력을 리스트로
arr = list(map(int, input().split()))
 
# 여러 개의 값
n, m = map(int, input().split())
 
# 정수 리스트
line = list(map(int, input().split()))

2차원 리스트 입력

행마다 입력을 받아 2차원 리스트를 구성한다. 리스트 컴프리헨션으로 한 줄로 줄일 수 있다.

# n개 행, m개 열
n, m = map(int, input().split())
grid = []
for _ in range(n):
    row = list(map(int, input().split()))
    grid.append(row)
 
# 리스트 컴프리헨션 버전, 위의 반복문과 동일하다.
grid = [list(map(int, input().split())) for _ in range(n)]

빠른 입력 (sys.stdin)

시간 제한이 타이트할 때는 sys.stdin.readline() 을 사용한다.

import sys
input = sys.stdin.readline
 
n = int(input())
arr = list(map(int, input().split()))

리스트 출력

*arr로 언팩킹하면 공백 구분으로 한 줄 출력된다. 세로 출력이 필요하면 join을 쓴다.

arr = [1, 2, 3, 4, 5]
 
# 방법 1: 언팩킹
print(*arr)  # 1 2 3 4 5
 
# 방법 2: join (문자열)
print('\n'.join(map(str, arr)))  # 1\n2\n3\n4\n5
 
# 방법 3: 루프
for x in arr:
    print(x)

빠른 출력

join은 구분자를 지정해서 문자열을 합칠 때 쓴다. 출력이 많을 때는 모아서 한 번에 출력하면 시간초과를 피할 수 있다.

import sys
result = []
for _ in range(n):
    result.append(str(answer))
print('\n'.join(result))

2. 자료형 비교

자료형문법가변순서중복조회수정비고
List[1, 2, 3]OOOO(1)O(1)O동적 배열
Tuple(1, 2, 3)XOOO(1)O(1)X해시 가능
Dict{k: v}OO (3.7+)X (key)O(1)O(1)Okey-value
Set{1, 2, 3}OXXO(1)O(1)O중복 제거

시간복잡도 정리

연산ListDictSet설명
조회O(n)O(n)O(1)O(1)O(1)O(1)Dict/Set은 해시 사용
삽입O(n)O(n)O(1)O(1)O(1)O(1)List 맨 앞 삽입은 O(n)O(n)
삭제O(n)O(n)O(1)O(1)O(1)O(1)위치를 알아야 함
정렬O(nlogn)O(n \log n)--비교 기반
in 연산O(n)O(n)O(1)O(1)O(1)O(1)Set/Dict이 훨씬 빠름

주요 메서드

자주 사용하는 메서드를 자료형별로 정리했다. 자료형에 따라 시간복잡도가 다르므로 주의할 필요가 있다.

List

arr.append(x)          # 맨 뒤에 추가
arr.extend([4, 5])     # 여러 원소 추가
arr.pop()              # 맨 뒤 제거, 반환
arr.pop(0)             # 첫 원소 제거 (O(n))
arr.insert(i, x)       # i번 위치에 삽입 (O(n))
arr.remove(x)          # 첫 x 제거 (O(n))
arr.reverse()          # 역순
arr.sort()             # 정렬 (인자 가능)
arr.count(x)           # x의 개수
arr.index(x)           # x의 위치

Tuple

t = (0, 2)             # 생성 (인덱스, 우선순위) 같은 묶음 값에 자주 쓴다
t[0]                   # 인덱싱으로 값 꺼내기 → 0
t[1]                   # → 2
 
idx, priority = t      # 언패킹으로 값 꺼내기 (t[0], t[1]과 같은 값)
 
# enumerate, dict.items(), zip은 모두 튜플을 만든다
# 반복문/제너레이터 표현식에서 바로 언패킹해서 받을 수 있다
for idx, priority in enumerate([2, 1, 3]):
    print(idx, priority)

Dict

d[key] = value         # 할당
d.get(key, default)    # key 없으면 default 반환
d.pop(key)             # key 제거, 값 반환
d.keys()               # 모든 key
d.values()             # 모든 value
d.items()              # (key, value) 쌍
d.update(other_dict)   # 다른 딕셔너리로 업데이트

Set

s.add(x)               # 추가
s.remove(x)            # 제거 (없으면 에러)
s.discard(x)           # 제거 (없으면 무시)
s.pop()                # 임의의 원소 제거
s & t                  # 교집합
s | t                  # 합집합
s - t                  # 차집합
s ^ t                  # 대칭차 (둘 중 하나만)

Truthy와 Falsy

빈 컬렉션, 0, None은 False로 평가된다. 스택/큐가 비었는지 확인할 때 if stk: 형태로 사용한다.

# Falsy: 0, None, [], {}, "", (), False
if not x:       # x가 거짓 값이면
    pass
 
# Truthy: 0이 아닌 수, 비어있지 않은 컨테이너, True
if x:           # x가 참 값이면
    pass

삼항 연산자

if-else를 한 줄로 쓰는 문법이다. 스택 문제에서 "비었으면 -1, 아니면 pop" 패턴에 자주 쓴다.

x = 10 if condition else 20

for-else

for 루프가 break 없이 끝까지 돌면 else가 실행된다. 검색 실패 처리에 유용하다. (예: 그룹 단어 체커)

arr = [1, 2, 3, 4, 6, 7]
 
for x in arr:
    if x == 5:
        print("found")
        break
else:
    print("not found")
  • 5를 찾으면 break 되어 else 실행이 안 된다.
  • 끝까지 못 찾으면 break 되지 않아 else가 실행된다.

그룹 단어 체커 문제에서는 한 단어를 검사하다가 이미 나왔던 문자가 다시 등장하면(연속이 아니라 떨어져서) break로 그 단어를 탈락시키고, 끝까지 문제 없이 다 돌면 else가 실행되어 그룹 단어로 센다.

n = int(input())
cnt = 0
 
for i in range(n):
    alpha = []
    s = input()
    prev = ''
    for t in s:
        if prev == t:
            prev = t
            continue
        if t in alpha:
            break
        else:
            alpha.append(t)
            prev = t
    else:
        cnt += 1
 
print(cnt)

3. 리스트 컴프리헨션

기본 문법

반복문으로 리스트를 생성하는 축약 문법이다. if만 쓸 때는 for 뒤에, if-elsefor 앞에 온다.

# 1차원
squares = [x**2 for x in range(10)]
 
# 조건부
evens = [x for x in range(10) if x % 2 == 0]
 
# 중첩
pairs = [(x, y) for x in range(3) for y in range(3)]
 
# 조건부 (if-else)
result = [x if x % 2 == 0 else -x for x in range(5)]  # [0, -1, 2, -3, 4]

고정 크기 배열 초기화

[0]*n은 1차원에서는 안전하지만, [[0]*m]*n은 모든 행이 같은 객체를 참조하므로 컴프리헨션을 써야 한다.

arr = [0] * 5                                    # 1D
grid = [[0] * 4 for _ in range(3)]               # 2D (올바른 방법)
grid = [[0] * 4] * 3                             # 2D (X: 같은 행 참조)

2차원 리스트 깊은 복사

2차원 리스트를 =로 복사하면 같은 객체를 참조한다. [row[:] for row in grid]로 독립적인 복사본을 만든다. 브루트포스에서 매번 원본을 유지한 채 시뮬레이션해야 할 때 자주 쓴다. (예: 연구소 문제처럼 매 조합마다 새 지도가 필요한 경우)

import copy
 
# 방법 1: copy 라이브러리
grid2 = copy.deepcopy(grid)
 
# 방법 2: 리스트 컴프리헨션
grid2 = [row[:] for row in grid]

연구소 문제에서는 벽 3개를 세우는 모든 조합마다 원본 지도를 건드리지 않고 시뮬레이션해야 해서, 매번 copy.deepcopy(grid)로 독립된 사본을 만들어 그 위에서만 확산시킨다.

from itertools import combinations
from collections import deque
import copy
 
best = 0
for walls in combinations(blanks, 3):
    tmp = copy.deepcopy(grid) 
    for x, y in walls:
        tmp[x][y] = 1
 
    q = deque(...)
    while q:
        x, y = q.popleft()
        for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            nx, ny = x + dx, y + dy
            if 0 <= nx < n and 0 <= ny < m and tmp[nx][ny] == 0:
                tmp[nx][ny] = 2
                q.append((nx, ny))
 
    count = sum(row.count(0) for row in tmp)
    best = max(best, count)

2차원 격자에서 좌표

이중 for문으로 격자의 각 칸을 행·열 인덱스와 함께 순회한다.

for i in range(len(grid)):
    for j in range(len(grid[i])):
        value = grid[i][j]

4. lambda와 정렬

lambda 기본

이름 없는 익명 함수를 만든다. 정렬의 key 파라미터에 주로 사용한다.

square = lambda x: x**2
print(square(5))  # 25
 
add = lambda x, y: x + y
print(add(3, 4))  # 7

sorted() vs sort()

sorted()는 새 리스트를 반환하고, sort()는 원본을 수정하며 None을 반환한다.

arr = [3, 1, 4, 1, 5]
 
# sorted(): 새로운 정렬된 리스트 반환
result = sorted(arr)  # [1, 1, 3, 4, 5]
 
# sort(): 리스트 내부에서 정렬 (반환값 없음)
arr.sort()  # arr = [1, 1, 3, 4, 5]

key 파라미터

정렬 기준을 지정한다. lambda와 함께 특정 원소나 조건을 기준으로 정렬할 수 있다.

# 절댓값
arr = [3, -1, -4, 2]
sorted(arr, key=abs)  # [-1, 2, 3, -4]
 
# 문자열 길이
words = ["apple", "pie", "a"]
sorted(words, key=len)  # ["a", "pie", "apple"]
 
# 두 번째 원소
pairs = [(1, 3), (1, 1), (2, 2)]
sorted(pairs, key=lambda x: x[1])  # [(1, 1), (2, 2), (1, 3)]

[3차] 파일명 정렬 문제에서는 정규식으로 파일명을 "HEAD·숫자·TAIL"로 쪼갠 뒤, (HEAD 소문자, 숫자) 튜플을 키로 삼아 정렬한다. 숫자를 문자열이 아니라 int로 바꿔서 키에 넣는 게 핵심인데, 그래야 "9""10"보다 뒤로 밀리는 문자열 비교 문제가 안 생긴다.

import re
 
def solution(files):
    t = []
    for f in files:
        match = re.match(r'([^0-9]+)([0-9]+)(.*)', f)
        head, number, tail = match.group(1), match.group(2), match.group(3)
        t.append((head.lower(), int(number), f)) 
 
    t = sorted(t, key=lambda x: (x[0], x[1])) 
 
    return [s[2] for s in t]

다중 조건 정렬

튜플을 반환하면 앞 원소부터 우선 정렬된다. 내림차순은 숫자에 -를 붙인다. (예: 나이순 정렬)

# 첫 번째 요소는 오름차순, 두 번째는 내림차순
students = [("Alice", 85), ("Bob", 85), ("Charlie", 90)]
sorted(students, key=lambda x: (x[1], -ord(x[0][0])))
 
# 또는 방법 2: 여러 번 sort() (역순으로, stable sort)
students.sort(key=lambda x: x[0])     # 이름순
students.sort(key=lambda x: x[1], reverse=True)  # 점수 내림차순

sort() 체이닝은 불가능하다. (sort는 None 반환)

arr.sort().sort()  # X - 에러

5. 내장함수

sum, max, min

기본 집계 함수다. key 파라미터로 기준을 바꿀 수 있다.

arr = [1, 3, 2, 5, 4]
sum(arr)           # 15
max(arr)           # 5
min(arr)           # 1
 
words = ["a", "abc", "ab"]
max(words, key=len)  # "abc"
 
sum(arr, 10)       # 25 (10부터 시작)

enumerate

인덱스와 값을 동시에 순회한다. start 파라미터로 시작 인덱스를 지정할 수 있다.

for i, val in enumerate(arr):
    print(i, val)  # (0, 1), (1, 3), (2, 2), ...
 
for i, val in enumerate(arr, start=1):
    print(i, val)  # (1, 1), (2, 3), ...

reversed

리스트를 뒤에서부터 순회한다. [::-1]과 달리 새 리스트를 만들지 않아 메모리 효율적이다.

for val in reversed(arr):
    print(val)
 
# 리스트로 변환
rev_arr = list(reversed(arr))

any / all

any()는 하나라도 참이면 True, all()은 모두 참이어야 True다.

arr = [1, 2, 3]
any(arr)           # True
all(arr)           # True
 
arr = [0, 1, 2]
any(arr)           # True
all(arr)           # False
 
any(x > 5 for x in arr)  # 5보다 큰 값이 있나?
all(x > 0 for x in arr)  # 모두 양수인가?

any(조건 for 변수 in 대상)은 for문을 한 줄로 압축한 것뿐이다. 아래 두 코드는 완전히 같은 뜻이다.

found = False
for x in arr:
    if x > 5:
        found = True
result = found
 
result = any(x > 5 for x in arr)

대상이 튜플들의 리스트라면, for 뒤에서 일반 for문과 똑같이 여러 변수로 풀어 받을 수 있다.

pairs = [(0, 2), (1, 1), (2, 3)]  # (인덱스, 우선순위)
 
any(priority > 2 for idx, priority in pairs)  # 우선순위가 2보다 큰 게 있나?

for idx, priority in pairsfor pair in pairs: idx, priority = pair를 줄여 쓴 것과 같다. 즉 튜플 하나(pair)를 두 변수(idx, priority)에 나눠 담는다.

프로세스에서는 큐(dq)에 (인덱스, 우선순위) 튜플이 쌓여있는 상황에서, "지금 꺼낸 것보다 우선순위 높은 게 큐 안에 남아있는지"를 한 줄로 확인한다.

dq = deque(enumerate(priorities))
current = dq.popleft()
 
if any(b > current[1] for a, b in dq): 
    dq.append(current)

여기서 a, b in dq는 큐에 남아있는 (인덱스, 우선순위) 튜플들을 하나씩 풀어 받는 거고, b(우선순위)만 current[1](방금 꺼낸 것의 우선순위)과 비교한다. a(인덱스)는 이 비교에서 안 쓰지만, 튜플을 풀어 받으려면 자리를 채워둬야 한다.

divmod

몫과 나머지를 동시에 반환한다. 시간 변환 문제에서 유용하다. (예: 시각)

quotient, remainder = divmod(17, 5)  # (3, 2)

zip

여러 iterable을 병렬로 순회한다. 행렬 전치에도 활용한다.

a = [1, 2, 3]
b = [4, 5, 6]
for x, y in zip(a, b):
    print(x, y)  # (1, 4), (2, 5), (3, 6)
 
# 행렬 전치
matrix = [[1, 2], [3, 4], [5, 6]]
transposed = list(zip(*matrix))  # [(1, 3, 5), (2, 4, 6)]

map / filter

map()은 각 원소에 함수를 적용하고, filter()는 조건에 맞는 원소만 추출한다.

nums = list(map(int, ["1", "2", "3"]))  # [1, 2, 3]
 
evens = list(filter(lambda x: x % 2 == 0, range(10)))  # [0, 2, 4, 6, 8]

6. 자주 쓰는 라이브러리

collections.deque

양쪽 끝에서 O(1)O(1) 시간에 추가/제거가 가능한 큐(Queue) 구조다. BFS에서 자주 사용한다. (예: )

from collections import deque
 
q = deque()
q.append(1)        # 오른쪽에 추가
q.appendleft(0)    # 왼쪽에 추가
q.pop()            # 오른쪽 제거, 반환
q.popleft()        # 왼쪽 제거, 반환
q[0]               # 첫 원소 접근

collections.Counter

원소의 개수를 세서 딕셔너리로 반환한다. (예: 숫자 카드 2)

from collections import Counter
 
s = "aabbcc"
c = Counter(s)     # Counter({'a': 2, 'b': 2, 'c': 2})
c['a']             # 2
c.most_common(2)   # [('a', 2), ('b', 2)]

collections.defaultdict

존재하지 않는 key에 접근하면 기본값을 자동 생성한다.

from collections import defaultdict
 
d = defaultdict(int)
d['a'] += 1        # key 없어도 0으로 초기화 후 1 추가
 
d = defaultdict(list)
d['group'].append(1)  # key 없어도 빈 리스트로 초기화

defaultdict가 필요한지는 "그 key에 대해 값을 읽어서 계산하는지, 아니면 그냥 새로 덮어쓰기만 하는지"로 구분한다. (예: 주차 요금 계산에서 입차 시각과 누적 시간을 각각 다른 딕셔너리로 관리할 때)

total[car] += 10       # total[car]를 먼저 "읽어서" 10을 더하는 것과 같다
                       # car가 없으면 읽을 값 자체가 없어서 KeyError
 
in_time[car] = now     # 그냥 새 값으로 덮어쓴다, 이전에 있었는지는 상관없다
                       # 일반 dict로 충분하다

+=, .append()처럼 "기존 값을 먼저 읽어서 계산해야 하는 경우"만 defaultdict가 필요하고, =처럼 "그냥 새로 덮어쓰기만 하는 경우"는 일반 dict로 충분하다.

heapq (최소 힙)

파이썬은 기본적으로 최소 힙을 제공한다. 최대 힙은 값에 -를 붙여서 구현한다. 이미 리스트가 있을 때는 heapify()O(n)O(n)에 힙으로 변환할 수 있다.

import heapq
 
heap = []
heapq.heappush(heap, 3)
heapq.heappush(heap, 1)
heapq.heappush(heap, 2)
 
heapq.heappop(heap)  # 1
heapq.heappop(heap)  # 2
 
nums = [3, 1, 2]
heapq.heapify(nums)  # O(n)

최대 힙 구현 (음수 값 저장)

heap = []
heapq.heappush(heap, -3)
heapq.heappush(heap, -1)
val = -heapq.heappop(heap)  # 3

더 맵게문제에서는 가장 작은 것 두 개를 뽑아서 섞고 다시 넣는 걸 반복한다.

import heapq
 
def solution(scoville, K):
    heapq.heapify(scoville)
 
    if scoville[0] >= K: 
        return 0
 
    answer = 0
    while len(scoville) > 1: 
        l1, l2 = heapq.heappop(scoville), heapq.heappop(scoville)
        t = l1 + l2 * 2
        heapq.heappush(scoville, t)
        answer += 1
        if scoville[0] >= K:
            return answer
 
    return -1

while len(scoville) > 1:로 섞을 재료가 2개 미만이면 루프를 멈춰서, 더 이상 섞을 수 없는 경우를 자연스럽게 -1로 떨어뜨린다. 루프 시작 전에 scoville[0] >= K를 한 번 확인하는 것도 중요한데, 없으면 이미 조건을 만족한 상태에서도 불필요하게 한 번 더 섞어버린다.

itertools (순열, 조합, 중복순열, 중복조합)

순서를 고려하면 순열(permutations), 순서 상관없이 고르면 조합(combinations), 같은 원소를 여러 번 뽑을 수 있으면 중복순열(product) 또는 중복조합(combinations_with_replacement)을 사용한다.

유형순서중복함수
순열OXpermutations
조합XXcombinations
중복순열OOproduct
중복조합XOcombinations_with_replacement

순열 (순서 O, 중복 X)N과 M (1)

from itertools import permutations
 
arr = [1, 2, 3]
for perm in permutations(arr):
    print(perm)  # (1,2,3), (1,3,2), (2,1,3), ...
 
# r개 선택 (순열)
for perm in permutations(arr, 2):
    print(perm)  # (1,2), (1,3), (2,1), (2,3), (3,1), (3,2)

조합 (순서 X, 중복 X)N과 M (2)

from itertools import combinations
 
arr = [1, 2, 3]
for comb in combinations(arr, 2):
    print(comb)  # (1,2), (1,3), (2,3)

중복순열 (순서 O, 중복 O)N과 M (3)

from itertools import product
 
for p in product([1, 2], ['a', 'b']):
    print(p)  # (1,'a'), (1,'b'), (2,'a'), (2,'b')
 
# repeat 파라미터로 자신과의 곱
for p in product([1, 2], repeat=2):
    print(p)  # (1,1), (1,2), (2,1), (2,2)

중복조합 (순서 X, 중복 O)N과 M (4)

from itertools import combinations_with_replacement
 
arr = [1, 2, 3]
for comb in combinations_with_replacement(arr, 2):
    print(comb)  # (1,1), (1,2), (1,3), (2,2), (2,3), (3,3)

단순히 모든 경우를 나열하기만 하면 itertools가 간편하다. 하지만 "합이 10 이하인 조합만" 같은 조건이 있으면 itertools는 전부 생성한 뒤 필터링해야 하므로 느리다. 이럴 때는 백트래킹으로 직접 구현하면 조건에 안 맞는 경우를 중간에 잘라낼 수 있어서 훨씬 빠르다.

bisect (이진 탐색)

정렬된 리스트에서 이진 탐색으로 삽입 위치를 찾는다.

import bisect
 
arr = [1, 3, 5, 7, 9]
 
# bisect_left: x가 들어갈 위치 (x 미만인 요소 개수)
bisect.bisect_left(arr, 5)   # 2
bisect.bisect_left(arr, 4)   # 2
 
# bisect_right: x 이하인 요소 개수
bisect.bisect_right(arr, 5)  # 3
bisect.bisect_right(arr, 4)  # 2
 
# insort: 정렬 상태 유지하며 삽입
bisect.insort(arr, 6)  # arr = [1, 3, 5, 6, 7, 9]

정렬된 배열에서 특정 범위에 값이 몇 개 있는지 O(logn)O(\log n)에 구할 수 있다. for문으로 세면 O(n)O(n)이다.

# arr에서 4 이상 7 이하인 값의 개수
left = bisect.bisect_left(arr, 4)
right = bisect.bisect_right(arr, 7)
count = right - left

math

import math
 
math.gcd(12, 8)         # 4
math.lcm(12, 8)         # 24
math.comb(5, 2)         # 10 (5C2)
math.perm(5, 2)         # 20 (5P2)
math.factorial(5)       # 120
math.sqrt(16)           # 4.0
math.ceil(3.2)          # 4
math.floor(3.8)         # 3
math.isqrt(17)          # 4 (정수 제곱근)

functools.lru_cache

재귀로 DP를 짤 때(탑다운), 같은 인자로 다시 호출되면 저장된 결과를 반환해주는 데코레이터다. 직접 memo 딕셔너리를 관리하지 않아도 된다.

lru_cache 없이 직접 메모이제이션하면 매번 딕셔너리를 관리해야 한다.

memo = {}
def fib(n):
    if n in memo:        # 이미 계산한 적 있으면
        return memo[n]   # 저장된 값 반환
    if n < 2:
        return n
    memo[n] = fib(n-1) + fib(n-2)  # 계산하고 저장
    return memo[n]

@lru_cache 한 줄을 붙이면 위와 동일하게 동작한다. 자동으로 결과를 캐싱해준다.

from functools import lru_cache
 
@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

7. 문자열/숫자 처리 패턴

정수 나눗셈

정수끼리 나눌 때는 //를 사용한다. /는 결과가 float이 되어 큰 수에서 정밀도가 깨질 수 있다.

17 // 5    # 3 (몫)
17 / 5     # 3.4 (실수)
17 % 5     # 2 (나머지)

swap

파이썬에서는 별도의 임시 변수 없이 한 줄로 값을 교환할 수 있다.

a, b = b, a  # 파이썬 스타일

ASCII 값

ord()는 문자를 아스키코드 숫자로, chr()는 숫자를 문자로 변환한다. (예: 알파벳 찾기)

ord('A')      # 65
chr(65)       # 'A'
ord('a') - ord('A')  # 32 (대소문자 차이)

슬라이싱과 불변성

[start:end:step] 형태로 부분 리스트/문자열을 추출한다. 문자열은 불변이므로 수정하려면 리스트로 변환해야 한다.

s = "hello"
s[1:3]        # "el"
s[::-1]       # "olleh" (역순)
s.replace('l', 'L')  # "heLLo" (새 문자열)
 
# 문자열은 불변이므로 s[0] = 'H' 불가능
# 리스트로 변환 후 다시 문자열로
chars = list(s)
chars[0] = 'H'
s = ''.join(chars)

문자열 ↔ 리스트

s = "123"
arr = list(s)          # ['1', '2', '3']
s = ''.join(arr)       # "123"
 
# 문자가 아닌 경우
nums = [1, 2, 3]
s = ''.join(map(str, nums))  # "123"

빈도 세기 (3가지 방법)

같은 결과를 3가지 방법으로 구현할 수 있다. (예: 숫자 카드 2)

from collections import Counter, defaultdict
 
s = "aabbcc"
 
# 방법 1: Counter
freq = Counter(s)
freq['a']    # 2
 
# 방법 2: dict.get()
freq = {}
for c in s:
    freq[c] = freq.get(c, 0) + 1
 
# 방법 3: defaultdict
freq = defaultdict(int)
for c in s:
    freq[c] += 1

좌표를 Set에 저장

BFS/DFS에서 2차원 좌표의 방문 여부를 체크할 때, 튜플로 Set에 저장하면 O(1)O(1)로 확인 가능하다.

visited = set()
visited.add((0, 0))
if (0, 0) in visited:
    print("이미 방문")

무한대

최솟값/최댓값을 찾을 때 초기값으로 사용한다.

float('inf')   # 양의 무한대
-float('inf')  # 음의 무한대
 
min_val = float('inf')
min_val = min(min_val, 10)  # 10

중복 제거 후 정렬

set으로 중복을 제거하고 sorted로 정렬하는 한 줄 패턴이다. (예: 좌표 압축)

arr = [3, 1, 2, 1, 3]
result = sorted(set(arr))  # [1, 2, 3]

좌표 압축 문제에서는 값이 몇 등인지(순위)까지 같이 매겨야 해서 한 줄로는 안 끝나고, 정렬된 배열을 훑으면서 "바로 앞 값과 다를 때만" 순위를 올려 defaultdict에 저장해둔다.

from collections import defaultdict
 
n = int(input())
s = list(map(int, input().split()))
ss = sorted(s)
 
rank = defaultdict(int)
cnt = 0
for i in range(1, len(ss)):
    if ss[i] != ss[i - 1]: 
        cnt += 1
    rank[ss[i]] = cnt
 
print(*[rank[x] for x in s])

두 구간의 겹치는 범위

left = max(a1, a2)
right = min(b1, b2)
if left <= right:
    # [left, right]가 겹치는 부분
    length = right - left + 1

8. 정렬

정렬 자체가 답인 문제도 있지만, 대부분은 정렬을 전처리로 쓰고 그 위에 다른 로직을 얹는다. 배열을 정렬해두면 이진 탐색, 투 포인터, 그리디 같은 알고리즘을 바로 적용할 수 있게 된다. 커스텀 기준으로 정렬해야 할 때는 key 파라미터나 functools.cmp_to_key를 활용한다.

K번째수 문제에서는 commands의 각 쿼리마다 array의 부분 구간을 잘라서(array[i-1:j]) 그때그때 정렬하고, k번째 값을 꺼낸다. 원본 배열을 건드리지 않고 매번 새 슬라이스를 정렬하기 때문에 쿼리끼리 서로 영향을 주지 않는다.

def solution(array, commands):
    answer = []
 
    for c in commands:
        i, j, k = c
        ta = sorted(array[i - 1:j]) 
        answer.append(ta[k - 1]) 
 
    return answer
연습 문제 (13)
문제출처난이도비고
K번째수프로그래머스Lv.1
H-Index프로그래머스Lv.2
가장 큰 수프로그래머스Lv.2
파일명 정렬프로그래머스Lv.2
인사고과프로그래머스Lv.3
기록 보관COJ 2317GOLD
점블COJ 2460GOLD
수 정렬하기BOJ 2750Bronze 2
수 정렬하기 2BOJ 2751Silver 5
통계학BOJ 2108Silver 4
좌표 정렬하기BOJ 11650Silver 5
좌표 정렬하기 2BOJ 11651Silver 5
나이순 정렬BOJ 10814Silver 5

9. 수학 / 소수

N 이하의 소수를 전부 구해야 하거나, 어떤 수가 소수인지 판별해야 할 때 이 유형이다. N이 크면 O(N)O(N)으로 하나씩 확인하면 시간초과가 나므로, 범위 내 모든 소수는 에라토스테네스의 체(O(NloglogN)O(N \log \log N))로, 단일 수 판별은 N\sqrt{N}까지만 확인하는 방식으로 줄인다. GCD/LCM은 math.gcd로 바로 쓸 수 있고, 약수 개수나 소인수분해가 목적이면 N\sqrt{N} 루프로 충분하다.

소수 찾기 문제에서는 sieve[0], sieve[1]을 처음부터 0으로 박아둬서 "0과 1은 소수가 아니다"를 따로 예외 처리 안 해도 되게 만들고, 마지막엔 남은 1의 개수를 그냥 sum()으로 센다.

import math
 
def solution(n):
    sieve = [1] * (n + 1)
    sieve[0] = 0
    sieve[1] = 0
 
    for i in range(2, int(math.sqrt(n)) + 1):
        if sieve[i]:
            for j in range(i * i, n + 1, i):
                sieve[j] = 0
 
    return sum(sieve) 
연습 문제 (13)
문제출처난이도비고
최대공약수와 최소공배수프로그래머스Lv.1
소수 찾기프로그래머스Lv.1
소수 만들기프로그래머스Lv.1
카펫프로그래머스Lv.2
멀쩡한 사각형프로그래머스Lv.2
숫자 블록프로그래머스Lv.2
정사각형 목장COJ 2461SILVER
승급 세기COJ 2414SILVER
소수 찾기BOJ 1978Bronze 2
소수 구하기BOJ 1929Silver 3
소인수분해BOJ 11653Bronze 1
최대공약수와 최소공배수BOJ 2609Bronze 2
골드바흐의 추측BOJ 6588Silver 1

10. 스택 / 큐

괄호 짝 맞추기, 뒤에서부터 처리, 현재보다 크거나 작은 다음 원소 찾기처럼 직전 상태를 기억하면서 처리해야 할 때 스택을 쓴다. 특히 오른쪽에서 처음 만나는 큰 수를 찾는 유형은 단조 스택의 대표 패턴이다. 큐는 순서대로 넣고 순서대로 꺼내야 할 때, BFS에서 방문 순서를 관리할 때 쓴다.

스택 (괄호 / 계산)

여는 괄호를 만나면 스택에 쌓고, 닫는 괄호를 만나면 꺼내서 짝을 맞춘다. 중첩된 구조나 이전 상태로 되돌아가야 하는 문제에 적합하다.

짝지어 제거하기 문제에서는 "괄호"가 아니라 "같은 문자 두 개가 연속으로 오면 서로 지워진다"는 규칙인데, 스택 원리는 똑같다. 지금 문자가 스택 맨 위와 같으면 짝이 맞아 지워버리고(pop), 다르면 새로 쌓는다.

def solution(s):
    stk = []
    for ch in s:
        if stk and stk[-1] == ch: 
            stk.pop() 
        else:
            stk.append(ch)
 
    return 1 if not stk else 0
연습 문제 (4)
문제출처난이도비고
올바른 괄호프로그래머스Lv.2
짝지어 제거하기프로그래머스Lv.2
괄호BOJ 9012Silver 4
괄호의 값BOJ 2504Gold 5

단조 스택

현재 원소보다 크거나 작은 다음 원소를 찾아야 할 때 쓴다. 스택에 인덱스를 쌓다가 현재 값이 스택 top보다 크면 pop하며 정답을 기록한다. O(n)O(n)에 해결된다.

스택에는 값이 아니라 인덱스를 저장한다. 스택에 남아있다는 건 "아직 자기보다 크거나 작은 상대를 못 만나서, 답을 아직 확정 못 한 원소"라는 뜻이고, 그 원소가 몇 번째였는지를 알아야 최종적으로 answer[인덱스] = 지금위치 - 인덱스 같은 계산을 할 수 있기 때문이다. 값만 저장하면 "그 값이 원래 몇 번째 자리에 있었는지"를 알 방법이 없어서 정답 배열을 채울 수 없다.

stk = []
for i, p in enumerate(prices):
    while stk and prices[stk[-1]] > p:  # 스택 top의 "값"은 prices[stk[-1]]로 조회
        idx = stk.pop()                  # 꺼내는 건 인덱스
        answer[idx] = i - idx            # 그 인덱스 자리에 답을 채움
    stk.append(i)

주식가격 문제에서는 스택에 쌓인 인덱스가 언제 떨어지는지(prices[stk[-1]] > p)를 만날 때마다 answer[idx] = i - idx로 그 자리의 답을 확정하고, 끝까지 한 번도 안 떨어져서 스택에 남은 인덱스들은 마지막 줄에서 "배열 끝까지의 시간"으로 한 번에 채운다.

def solution(prices):
    answer = [0] * len(prices)
    stk = []
 
    for i, p in enumerate(prices):
        while stk and prices[stk[-1]] > p:
            idx = stk.pop() 
            answer[idx] = i - idx 
        stk.append(i)
 
    for idx in stk:
        answer[idx] = (len(prices) - 1) - idx
 
    return answer

끝까지 한 번도 안 떨어져서 스택에 남아있는 인덱스들은, 루프가 끝난 뒤 마지막 for idx in stk:에서 "끝까지 안 떨어졌으니 배열 끝까지의 시간"으로 따로 채운다.

연습 문제 (2)
문제출처난이도비고
주식가격프로그래머스Lv.2
오큰수BOJ 17298Gold 4

큐 (순서 시뮬레이션)

순서대로 처리하고 결과를 다시 뒤에 넣는 시뮬레이션 문제에 쓴다.

프로세스 문제에서는 맨 앞을 꺼내봤을 때, 큐 안에 그보다 우선순위 높은 게 남아있으면 다시 뒤로 보내고(dq.append), 없으면 실행 순서를 확정한다. (인덱스, 우선순위)를 튜플로 같이 들고 다녀서, 원래 몇 번째 프로세스였는지 잃어버리지 않는다.

from collections import deque
 
def solution(priorities, location):
    dq = deque(enumerate(priorities))
    order = 0
 
    while dq:
        current = dq.popleft()
 
        if any(b > current[1] for a, b in dq): 
            dq.append(current) 
        else:
            order += 1
            if current[0] == location:
                return order
연습 문제 (5)
문제출처난이도비고
기능개발프로그래머스Lv.2
프로세스프로그래머스Lv.2
다리를 지나는 트럭프로그래머스Lv.2
Don't Be Last!COJ 2475GOLD
소 조깅(SILVER)COJ 2365GOLD

11. 힙 (Priority Queue)

현재 가장 작거나 큰 것을 반복적으로 꺼내야 할 때 힙을 쓴다. 매번 정렬하면 O(nlogn)O(n \log n)이 쌓이지만, 힙은 삽입/삭제가 O(logn)O(\log n)이라 훨씬 효율적이다. 파이썬은 최소 힙만 기본 제공하므로, 최대 힙이 필요하면 값에 -를 붙여서 넣고 꺼낼 때 다시 -를 붙인다.

더 맵게 문제에서는 가장 작은 것 두 개를 뽑아서 섞고 다시 넣는 걸 반복한다.

import heapq
 
def solution(scoville, K):
    heapq.heapify(scoville)
 
    if scoville[0] >= K: 
        return 0
 
    answer = 0
    while len(scoville) > 1: 
        l1, l2 = heapq.heappop(scoville), heapq.heappop(scoville)
        t = l1 + l2 * 2
        heapq.heappush(scoville, t)
        answer += 1
        if scoville[0] >= K:
            return answer
 
    return -1

루프 시작 전에 scoville[0] >= K를 먼저 확인하지 않으면, 이미 조건을 만족한 상태에서도 불필요하게 한 번 더 섞어버린다. while len(scoville) > 1:은 섞을 재료가 2개 미만이면 멈춰서, 더 이상 섞을 수 없는 경우를 자연스럽게 -1로 떨어뜨린다.

연습 문제 (8)
문제출처난이도비고
더 맵게프로그래머스Lv.2
디스크 컨트롤러프로그래머스Lv.3
이중우선순위큐프로그래머스Lv.3
미세 온도 제어COJ 2422GOLD
최소 힙BOJ 1927Silver 2
최대 힙BOJ 11279Silver 2
절댓값 힙BOJ 11286Silver 1
카드 정렬하기BOJ 1715Gold 4

12. 해시 / 딕셔너리

특정 값이 있는지 빠르게 확인하거나, 등장 횟수를 세야 할 때 딕셔너리를 쓴다. 리스트에서 in 연산은 O(n)O(n)이지만 딕셔너리는 O(1)O(1)이라 데이터가 많을수록 차이가 커진다. 빈도 계산은 Counter, 키 없을 때 기본값이 필요하면 defaultdict를 쓰면 코드가 간결해진다.

정렬 후 인접한 것만 비교하면 되는 예시다. 전화번호 목록에서, 어떤 번호가 다른 번호의 접두어라면 정렬했을 때 반드시 인접한 위치에 온다는 성질을 이용한다.

def solution(phone_book):
    s = sorted(phone_book)
 
    for i in range(len(s) - 1):
        if s[i + 1].startswith(s[i]): 
            return False
 
    return True

Counter끼리 ==로 통째로 비교하는 예시다. 할인 행사에서, 10일치 윈도우와 원하는 목록을 각각 Counter로 만들어 비교한다.

from collections import Counter
 
def solution(want, number, discount):
    s = []
    for i in range(len(want)):
        for j in range(number[i]):
            s.append(want[i])
    wc = Counter(s)
 
    answer = 0
    for i in range(len(discount) - 9):
        tc = Counter(discount[i:i + 10])
        if wc == tc: 
            answer += 1
 
    return answer
연습 문제 (12)
문제출처난이도비고
완주하지 못한 선수프로그래머스Lv.1
전화번호 목록프로그래머스Lv.2
할인 행사프로그래머스Lv.2
위장프로그래머스Lv.2
베스트앨범프로그래머스Lv.3
오픈채팅방프로그래머스Lv.2
마력 수치 계산COJ 2458GOLD
공통 수COJ 2457GOLD
빈도수COJ 2456GOLD
직선의 개수 구하기COJ 2455GOLD
두 집합의 차집합 구하기COJ 2454GOLD
나는야 포켓몬 마스터 이다솜BOJ 1620Silver 4

13. 투 포인터

배열에서 두 원소의 관계나 특정 구간을 찾아야 할 때, O(n2)O(n^2) 브루트포스 대신 O(n)O(n)으로 풀 수 있는 기법이다. 포인터 두 개를 이동시키며 탐색하므로, 각 포인터가 한 방향으로만 이동한다는 조건이 성립해야 한다.

구간 축소형 (한쪽에서 시작, 같은 방향)

조건을 만족하는 가장 짧거나 긴 연속 구간을 찾아야 할 때 쓴다. 한 포인터로 구간을 늘리고, 조건이 충족되면 다른 포인터로 구간을 줄이며 최적값을 갱신한다.

i = 0
j = 0
while i < len(arr):
    # i로 윈도우 확장
    window_size = i - j + 1
 
    # 조건 확인 및 j 진행
    while condition:
        # j로 윈도우 축소
        j += 1
 
    i += 1

합이 s 이상인 가장 짧은 부분배열을 찾는 예시다. (예: 부분합)

arr = [2, 3, 1, 2, 4, 3]
s = 7
 
i = 0
j = 0
min_len = float('inf')
current_sum = 0
 
while i < len(arr):
    current_sum += arr[i]
    i += 1
 
    while current_sum >= s:
        min_len = min(min_len, i - j)
        current_sum -= arr[j]
        j += 1
 
print(min_len)  # 2 ([4, 3])
연습 문제 (3)
문제출처난이도비고
수 고르기BOJ 2230Gold 5
수들의 합 2BOJ 2003Silver 4
부분합BOJ 1806Gold 4

정확히 k인 구간 찾기 (윈도우 크기 계산 순서 주의)

"합이 k 이상"이 아니라 "합이 정확히 k"인 구간을 찾아야 할 때는, 윈도우 크기를 계산하는 위치를 조심해야 한다. (예: 연속된 부분 수열의 합)

# X: 축소하기 전에 윈도우 크기를 미리 계산
while i < len(arr):
    csum += arr[i]
    wsize = i - j + 1        # j가 아직 줄어들기 전 값
    while csum > k:
        csum -= arr[j]
        j += 1
    if csum == k and wsize < best:
        ...                   # 실제 최종 구간 크기와 다른 값으로 비교하게 됨
    i += 1
 
# O: 축소가 다 끝난 뒤에 계산
while i < len(arr):
    csum += arr[i]
    while csum > k:
        csum -= arr[j]
        j += 1
    wsize = i - j + 1        # j가 다 줄어든 뒤의 진짜 값
    if csum == k and wsize < best:
        ...
    i += 1

wsize = i - j + 1은 그 순간의 j로 구간 길이를 재는 계산이다. while csum > k: 루프가 j를 계속 줄이는데, 그 전에 미리 wsize를 구해버리면 "아직 줄어들기 전, 옛날 j" 기준의 길이를 들고 있게 된다. 반의 학생 수를 세는 순간엔 30명이었다가 그 직후 5명이 전학 가서 실제로는 25명이 됐는데, 손에 든 숫자는 여전히 "30명"이라고 믿고 있는 상황과 같다. "합이 k 이상"처럼 축소 루프 안에서 매번 확인하는 경우는 상관없지만, "합이 정확히 k"처럼 축소가 끝난 뒤 단 한 번만 확인하는 경우에는 반드시 while 루프 뒤에서 wsize를 계산해야 한다.

연속된 부분 수열의 합 문제에서는 csum > k인 동안 왼쪽을 다 줄인 뒤에야 wsize를 계산해서, 그 순간 csum == k면 지금까지 찾은 최단 구간(bw)보다 짧은지 비교한다. 길이가 같으면 나중 걸로 안 덮어써지도록 <(등호 없음)로 비교해서, 먼저 찾은(더 앞쪽) 구간이 자동으로 유지된다.

def solution(sequence, k):
    answer = []
    i, j = 0, 0
    csum = 0
    bw = float('inf')
 
    while i < len(sequence):
        csum += sequence[i]
        while csum > k:
            csum -= sequence[j]
            j += 1
        wsize = i - j + 1
        if csum == k and wsize < bw:
            answer = [j, i]
            bw = wsize
        i += 1
 
    return answer
연습 문제 (1)
문제출처난이도비고
연속된 부분 수열의 합프로그래머스Lv.3

양 끝 수렴형 (양 끝에서 시작, 좁혀옴)

정렬된 배열에서 두 원소의 합이 특정 조건을 만족하는 쌍을 찾을 때 쓴다. 합이 크면 오른쪽 포인터를 줄이고, 작으면 왼쪽 포인터를 늘리는 방식으로 O(n)O(n)에 해결한다. 배열이 정렬되어 있어야 한다는 전제가 필요하다.

left = 0
right = len(arr) - 1
 
while left < right:
    total = arr[left] + arr[right]
    if total > target:
        right -= 1    # 합이 크면 오른쪽을 줄이고
    elif total < target:
        left += 1     # 합이 작으면 왼쪽을 늘린다
    else:
        break         # 정확히 맞으면 종료

정렬된 배열에서 두 수의 합이 0에 가장 가까운 쌍을 찾는 예시다. (예: 두 용액)

arr = [-99, -2, -1, 4, 98]
 
left = 0
right = len(arr) - 1
best = float('inf')
ans_l, ans_r = 0, 0
 
while left < right:
    total = arr[left] + arr[right]
 
    if abs(total) < best:
        best = abs(total)
        ans_l, ans_r = arr[left], arr[right]
 
    if total > 0:
        right -= 1
    elif total < 0:
        left += 1
    else:
        break
 
print(ans_l, ans_r)  # -99 98

용액 문제에서는 정확히 위 예시와 같은 패턴을 쓰는데, "0에 가장 가까운 합"을 찾는 부분만 남기고 abs(total) < best로 갱신한다.

n = int(input())
s = list(map(int, input().split()))
 
left = 0
right = n - 1
best = float('inf')
ansl, ansr = 0, 0
 
while left < right:
    total = s[left] + s[right]
 
    if abs(total) < best: 
        best = abs(total) 
        ansl, ansr = s[left], s[right] 
 
    if total > 0:
        right -= 1
    elif total < 0:
        left += 1
    else:
        break
 
print(ansl, ansr)
연습 문제 (4)
문제출처난이도비고
두 수의 합BOJ 3273Silver 3
주몽BOJ 1940Silver 4
두 용액BOJ 2470Gold 5
용액BOJ 2467Gold 3

14. 구간합 (Prefix Sum)

같은 구간의 합을 여러 번 물어보는 문제에서 매번 합을 구하면 O(n×q)O(n \times q)(qq는 쿼리 수)가 된다. 미리 누적합 배열을 만들어두면 각 쿼리를 O(1)O(1)에 처리할 수 있다. 단, 배열 원소가 변하지 않을 때만 쓸 수 있다. 값이 바뀌면서 구간합도 물어보면 세그먼트 트리를 써야 한다.

1D Prefix Sum

배열이 1차원이고 특정 구간 [l, r]의 합을 반복적으로 구해야 할 때 쓴다. 미리 합 배열을 만들어두면 구간 합을 뺄셈 한 번으로 구할 수 있다.

n = len(arr)
prefix = [0] * (n + 1)
for i in range(n):
    prefix[i+1] = prefix[i] + arr[i]
 
def range_sum(l, r):
    return prefix[r+1] - prefix[l]

1D prefix sum 사용 예시다. (예: 구간 합 구하기 4)

arr = [1, 2, 3, 4, 5]
n = len(arr)
prefix = [0] * (n + 1)
 
for i in range(n):
    prefix[i+1] = prefix[i] + arr[i]
 
# [1, 3] 범위 합
print(prefix[4] - prefix[1])  # 9 (2+3+4)
연습 문제 (7)
문제출처난이도비고
품종 세기COJ 2405GOLD
COWCOJ 2383GOLD
게으른 소COJ 2342GOLD
구간의 합 구하기 (1D)정올 3135입문
수열BOJ 2559Silver 3
나머지 합BOJ 10986Gold 3
구간 합 구하기 4BOJ 11659Silver 3

2D Prefix Sum

입력이 2차원 배열이고 특정 직사각형 범위의 합을 반복적으로 구해야 할 때 쓴다. 위 + 왼 - 대각 + 현재값으로 prefix 배열을 채운다.

rows, cols = len(grid), len(grid[0])
prefix = [[0] * (cols + 1) for _ in range(rows + 1)]
 
for i in range(1, rows + 1):
    for j in range(1, cols + 1):
        prefix[i][j] = grid[i-1][j-1] \
                       + prefix[i-1][j] \
                       + prefix[i][j-1] \
                       - prefix[i-1][j-1]

전체 - 위 - 왼 + 겹침으로 직사각형 구간 합을 구한다.

def range_sum_2d(r1, c1, r2, c2):
    return prefix[r2+1][c2+1] \
           - prefix[r1][c2+1] \
           - prefix[r2+1][c1] \
           + prefix[r1][c1]

2D prefix sum 전체 코드다. (예: 구간 합 구하기 5)

grid = [[1, 2], [3, 4], [5, 6]]
rows, cols = len(grid), len(grid[0])
 
prefix = [[0] * (cols + 1) for _ in range(rows + 1)]
for i in range(1, rows + 1):
    for j in range(1, cols + 1):
        prefix[i][j] = grid[i-1][j-1] \
                       + prefix[i-1][j] \
                       + prefix[i][j-1] \
                       - prefix[i-1][j-1]
 
def range_sum_2d(r1, c1, r2, c2):
    return prefix[r2+1][c2+1] \
           - prefix[r1][c2+1] \
           - prefix[r2+1][c1] \
           + prefix[r1][c1]
 
print(range_sum_2d(0, 0, 2, 1))  # 1+2+3+4+5+6 = 21

정수로 할 수 있는 계산은 먼저 정수로 하고, 나누기는 마지막에 한다.

# 비교: a/b < c/d
# X: a/b < c/d (부동소수점 오차)
# O: a*d < c*b (정수 연산)
연습 문제 (2)
문제출처난이도비고
구간의 합 구하기 (2D)정올 3136입문
파괴되지 않은 건물프로그래머스Lv.3

2D 차이 배열 (구간 갱신)

Prefix Sum이 "이미 고정된 배열에서 구간 합을 빠르게 조회"하는 기법이라면, 차이 배열(difference array)은 그 반대다. "구간에 값을 여러 번 더하거나 빼는 작업을 반복한 뒤, 마지막에 딱 한 번만 실제 값을 복원"해야 할 때 쓴다. 직사각형 범위를 매번 순회하며 갱신하면 (범위 크기) × (갱신 횟수)만큼 시간이 걸려서 큰 입력에서는 시간 초과가 난다. (예: 파괴되지 않은 건물)

1차원에서는 구간 [l, r]에 값 delta를 더하고 싶을 때, 시작 지점에 +delta, 끝나는 지점 바로 다음에 -delta만 표시해두고 나중에 누적합을 한 번 구하면 그 구간에만 정확히 값이 적용된다.

diff = [0] * (n + 1)
diff[l] += delta
diff[r + 1] -= delta
 
# 나중에 한 번만 누적합
for i in range(1, n):
    diff[i] += diff[i - 1]

2차원 직사각형 (r1, c1) ~ (r2, c2)에 값을 적용하려면, 네 모서리에 부호를 다르게 표시한 뒤 가로 누적합과 세로 누적합을 순서대로 한 번씩 거친다. 부호는 "행 방향 부호 × 열 방향 부호"로 외우면 헷갈리지 않는다. 시작 지점(r1, c1)은 +, 끝나는 지점 다음(r2+1, c2+1)은 -이므로, 두 부호를 곱하면 왼쪽 위와 오른쪽 아래는 +(같은 부호끼리 곱함), 오른쪽 위와 왼쪽 아래는 -(다른 부호끼리 곱함)가 된다.

n, m = len(board), len(board[0])
diff = [[0] * m for _ in range(n)]
 
for r1, c1, r2, c2, delta in updates:
    diff[r1][c1] += delta
    if c2 + 1 < m:
        diff[r1][c2 + 1] -= delta
    if r2 + 1 < n:
        diff[r2 + 1][c1] -= delta
    if r2 + 1 < n and c2 + 1 < m:
        diff[r2 + 1][c2 + 1] += delta
 
# 가로 누적합 → 세로 누적합, 순서를 지켜야 한다
for r in range(n):
    for c in range(1, m):
        diff[r][c] += diff[r][c - 1]
 
for c in range(m):
    for r in range(1, n):
        diff[r][c] += diff[r - 1][c]
 
# diff[r][c]가 (r, c) 칸에 최종적으로 적용된 값

가로 누적합만 하면 표시가 안 된 행(예: 직사각형 중간 행)은 계속 0으로 남는다. 값이 세로 방향으로도 퍼져야 직사각형 내부 전체에 반영되므로, 가로 누적합 뒤에 반드시 세로 누적합을 이어서 해야 한다.

파괴되지 않은 건물 문제에서는 skill의 각 행이 [type, r1, c1, r2, c2, degree]인데, 공격(type=1)이면 -degree, 회복(type=2)이면 +degree로 부호만 바꿔서 델타값을 만들고, 그 델타를 네 모서리에 표시한다. 직사각형이 배열 경계에 딱 붙어있는 경우(c2+1 == m 또는 r2+1 == n)는 표시할 자리 자체가 없으므로 if로 걸러서 건너뛴다. 이 표시를 전부 끝낸 뒤에야 가로·세로 누적합으로 실제 값을 복원하고, 마지막에 원래 내구도(board)와 더해서 1 이상인 칸의 개수를 센다.

def solution(board, skill):
    n = len(board)
    m = len(board[0])
    diff = [[0] * m for _ in range(n)]
 
    for s in skill: 
        type_, r1, c1, r2, c2, degree = s 
        delta = degree if type_ == 2 else -degree 
        diff[r1][c1] += delta 
        if c2 + 1 < m: 
            diff[r1][c2 + 1] -= delta 
        if r2 + 1 < n: 
            diff[r2 + 1][c1] -= delta 
        if r2 + 1 < n and c2 + 1 < m: 
            diff[r2 + 1][c2 + 1] += delta 
 
    for r in range(n):
        for c in range(1, m):
            diff[r][c] += diff[r][c - 1] 
 
    for c in range(m):
        for r in range(1, n):
            diff[r][c] += diff[r - 1][c] 
 
    answer = 0
    for r in range(n):
        for c in range(m):
            if board[r][c] + diff[r][c] >= 1:
                answer += 1
 
    return answer
연습 문제 (2)
문제출처난이도비고
파괴되지 않은 건물프로그래머스Lv.3
보리볏 쌓기COJ 2199GOLD

15. DFS / BFS

BFS (기본형)

가장 빠른 경로나 최소 이동 횟수를 묻는다면 BFS다. BFS는 가중치 없는 그래프에서 처음 도달한 시점이 곧 최단거리임을 보장한다. 가중치가 있으면 다익스트라를 써야 한다. 큐에 넣는 순간 visited를 표시해야 중복 삽입을 막을 수 있다.

from collections import deque
 
n = ...  # 노드 수
v = [False] * (n + 1)
 
def bfs(start):
    queue = deque()
    queue.append(start)
    v[start] = True
 
    while queue:
        node = queue.popleft()
 
        for neighbor in graph[node]:
            if not v[neighbor]:
                v[neighbor] = True
                queue.append(neighbor)

게임 맵 최단거리 문제에서는 격자형이라 visited 대신 dist 배열 하나로 "방문 여부"와 "거리"를 동시에 관리한다.

from collections import deque
 
def solution(maps):
    n = len(maps)
    m = len(maps[0])
    dq = deque()
    dq.append((0, 0))
    dist = [[0] * m for _ in range(n)] 
    dist[0][0] = 1
 
    dx = [-1, 0, 1, 0]
    dy = [0, -1, 0, 1]
 
    while dq:
        x, y = dq.popleft()
        for i in range(4):
            nx, ny = x + dx[i], y + dy[i]
            if 0 <= nx < n and 0 <= ny < m and maps[nx][ny] == 1 and dist[nx][ny] == 0: 
                dq.append((nx, ny))
                dist[nx][ny] = dist[x][y] + 1
 
    return dist[-1][-1] if dist[-1][-1] != 0 else -1

dist[nx][ny] == 0이 곧 "아직 방문 안 함"이고, 방문하는 순간 dist[x][y] + 1이 들어가서 거리도 같이 기록된다.

연습 문제 (6)
문제출처난이도비고
게임 맵 최단거리프로그래머스Lv.2
단어 변환프로그래머스Lv.3
메시지 릴레이COJ 2263GOLD
DFS와 BFSBOJ 1260Silver 2
숨바꼭질BOJ 1697Silver 1
나이트의 이동BOJ 7562Silver 1

BFS (멀티소스)

출발점이 하나가 아니라 여러 곳이고, 동시에 퍼져나가는 상황이면 멀티소스 BFS다. 시작점을 전부 큐에 넣고 BFS를 돌리면 각 지점까지의 최단거리가 나온다. 특정 조건을 만족한 모든 칸을 시작점으로 삼아야 하는 문제가 대표적이다.

from collections import deque
 
queue = deque()
dist = {}
 
for source in sources:   # 시작점 전부 큐에 삽입
    queue.append(source)
    dist[source] = 0
 
while queue:
    node = queue.popleft()
    for neighbor in graph[node]:
        if neighbor not in dist:
            dist[neighbor] = dist[node] + 1
            queue.append(neighbor)

토마토 (3D) 문제에서는 이미 익은 토마토(1)가 여러 개 흩어져 있으니, 그 칸들을 전부 큐에 미리 넣어두고 한 번에 BFS를 돌린다. 3차원이라 방향 배열이 6개(위/아래/좌/우/앞/뒤)로 늘어난 것 말고는 격자형 BFS와 똑같다.

from collections import deque
 
m, n, h = map(int, input().split())
q = deque()
 
arr = [[list(map(int, input().split())) for _ in range(n)] for _ in range(h)]
dist = [[[-1] * m for _ in range(n)] for _ in range(h)]
 
for k in range(h): 
    for j in range(n): 
        for i in range(m): 
            if arr[k][j][i] == 1: 
                q.append((k, j, i)) 
                dist[k][j][i] = 0
 
dx = [-1, 1, 0, 0, 0, 0]
dy = [0, 0, -1, 1, 0, 0]
dz = [0, 0, 0, 0, -1, 1]
 
while q:
    z, y, x = q.popleft()
    for k in range(6):
        nx, ny, nz = x + dx[k], y + dy[k], z + dz[k]
        if 0 <= nx < m and 0 <= ny < n and 0 <= nz < h:
            if arr[nz][ny][nx] == 0 and dist[nz][ny][nx] == -1:
                dist[nz][ny][nx] = dist[z][y][x] + 1
                q.append((nz, ny, nx))
 
ans = -1
for k in range(h):
    for j in range(n):
        for i in range(m):
            if arr[k][j][i] == 0 and dist[k][j][i] == -1:
                print(-1)
                exit()
            ans = max(ans, dist[k][j][i])
 
print(ans)
연습 문제 (3)
문제출처난이도비고
말편자COJ 2234GOLD
토마토 (3D)BOJ 7569Gold 5
소 미인대회COJ 2166GOLD

BFS (격자형)

입력이 2차원 배열이고 인접한 칸을 상하좌우로 이동하며 탐색하는 문제면 격자형 BFS다. dx/dy 배열로 4방향(또는 8방향)을 처리하고, 범위 체크와 visited 체크를 함께 한다.

from collections import deque
 
dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]
 
def grid_bfs(grid, sr, sc):
    rows, cols = len(grid), len(grid[0])
    queue = deque([(sr, sc)])
    visited = [[False] * cols for _ in range(rows)]
    visited[sr][sc] = True
 
    while queue:
        x, y = queue.popleft()
        for i in range(4):
            nx, ny = x + dx[i], y + dy[i]
            if 0 <= nx < rows and 0 <= ny < cols \
               and not visited[nx][ny] and grid[nx][ny] != 0:
                visited[nx][ny] = True
                queue.append((nx, ny))
연습 문제 (2)
문제출처난이도비고
미로 탐색BOJ 2178Silver 1
단지번호붙이기BOJ 2667Silver 1

DFS (재귀)

연결된 그룹이 몇 개인지 세거나, 두 노드가 연결되어 있는지 확인하거나, 가능한 모든 경우를 탐색해야 할 때 DFS를 쓴다. 최단거리는 보장하지 않으므로 가장 빠른 경로가 목적이면 BFS가 맞다. 코드가 간결하지만 재귀 깊이 제한(기본 1000)에 주의한다. 노드가 많으면 sys.setrecursionlimit(10**6)을 추가한다.

import sys
sys.setrecursionlimit(10**6)
 
v = [False] * (n + 1)
 
def dfs(node):
    v[node] = True
    for neighbor in graph[node]:
        if not v[neighbor]:
            dfs(neighbor)

네트워크 문제에서는 "몇 개의 그룹으로 나뉘는지" 세는 문제라, 아직 방문 안 한 컴퓨터를 만날 때마다 그 컴퓨터에서 갈 수 있는 곳을 전부 DFS로 방문 처리하고 answer를 하나 늘린다. computers[node][j] == 1이 인접행렬에서 연결 여부를 확인하는 부분이다.

def solution(n, computers):
    visit = [0 for _ in range(n)]
 
    def dfs(node):
        visit[node] = 1
        for j in range(n):
            if computers[node][j] == 1 and not visit[j]: 
                dfs(j) 
 
    answer = 0
    for i in range(n):
        if not visit[i]:
            dfs(i)
            answer += 1
 
    return answer

DFS (스택)

재귀 깊이 제한을 피해야 할 때 스택으로 직접 구현한다. 동작은 재귀 DFS와 같지만 탐색 순서가 다를 수 있다.

v = [False] * (n + 1)
 
def dfs_stack(start):
    stack = [start]
    v[start] = True
 
    while stack:
        node = stack.pop()
        for neighbor in graph[node]:
            if not v[neighbor]:
                v[neighbor] = True
                stack.append(neighbor)
연습 문제 (4)
문제출처난이도비고
타겟 넘버프로그래머스Lv.2
네트워크프로그래머스Lv.3
여행경로프로그래머스Lv.3
바이러스BOJ 2606Silver 3

16. 다익스트라 / 벨만-포드

다익스트라 (기본형)

시작점에서 모든 노드까지의 최단거리를 구한다. 음수 간선이 없을 때 사용한다. visit 배열로 이미 처리된 노드를 건너뛴다. 단일 출발점 최단거리 문제에 바로 대입한다.

import heapq
 
INF = float('inf')
g = [[] for _ in range(v + 1)]  # g[s].append((c, e))
dist = [INF] * (v + 1)
visit = [False] * (v + 1)
 
q = []
heapq.heappush(q, (0, start))
dist[start] = 0
 
while q:
    cost, u = heapq.heappop(q)
    if visit[u]:
        continue
    visit[u] = True
    for w, t in g[u]:
        if cost + w < dist[t]:
            dist[t] = cost + w
            heapq.heappush(q, (dist[t], t))

gq 모두 (비용, 노드) 순서로 통일해서 담는다. q는 힙이라 첫 번째 원소 기준으로 최솟값을 꺼내야 하므로 비용이 반드시 앞에 와야 하는데(그래야 "가장 가까운 노드부터 처리한다"는 다익스트라의 핵심 원칙이 성립함), g는 원래 순서가 자유롭지만 q와 다르게 두면 for 문마다 순서를 헷갈리기 쉽다. 그래서 g도 같은 순서로 맞춰서, for w, t in g[u]:처럼 항상 "비용 먼저, 노드 나중"으로 통일해서 읽는다.

배달 문제에서는 "K 이하로 배달 가능한 마을 개수"를 구하는 문제라, 마지막에 dist를 순회하며 조건에 맞는 개수만 센다.

import heapq
 
def solution(N, road, K):
    INF = float('inf')
    dist = [INF] * (N + 1)
    visit = [False] * (N + 1)
    q = []
    g = [[] for _ in range(N + 1)]
    for a, b, c in road: 
        g[a].append((c, b)) 
        g[b].append((c, a)) 
 
    heapq.heappush(q, (0, 1))
    dist[1] = 0
 
    while q:
        cost, u = heapq.heappop(q)
        if visit[u]:
            continue
        visit[u] = True
        for w, t in g[u]:
            if cost + w < dist[t]:
                dist[t] = cost + w
                heapq.heappush(q, (dist[t], t)) 
 
    return sum(d <= K for d in dist[1:]) 

road가 양방향이라 g[a], g[b] 양쪽에 다 등록해야 한다는 게 이 문제만의 포인트다. 마지막 줄은 True/False1/0으로 취급되는 걸 이용해 sum()으로 조건 만족 개수를 한 줄에 센다.

연습 문제 (16)
문제출처난이도비고
그래프 최소 비용SWEA 5263D4
짝수 만들기COJ 2392GOLD
소 번호 매기기COJ 2208GOLD
배달 경로COJ 2201EMERALD
두 GPS의 결투COJ 2354EMERALD
도로 차단COJ 2337EMERALD
물류 배송COJ 2413PLATINUM
배달프로그래머스Lv.2
최소비용COJ 2196GOLD
최단경로BOJ 1753Gold 4
최소비용 구하기BOJ 1916Gold 5
녹색 옷 입은 애가 젤다지?BOJ 4485Gold 4
특정한 최단 경로BOJ 1504Gold 4
알고스팟BOJ 1261Gold 4
택배 배송BOJ 5972Gold 5
해킹BOJ 10282Gold 4

다익스트라 (비용 비교형)

visit 배열 없이 힙에서 꺼낸 비용과 현재 저장된 비용을 비교해 중복 처리를 방지한다. 다익스트라를 여러 번 실행해야 할 때 함수로 분리해서 쓴다.

import heapq
 
def dijkstra(start):
    INF = float('inf')
    dist = [INF] * (n + 1)
    dist[start] = 0
    pq = []
    heapq.heappush(pq, (0, start))
 
    while pq:
        cost, u = heapq.heappop(pq)
        if dist[u] < cost:  # 이미 더 짧은 경로로 처리됨
            continue
        for w, t in g[u]:
            if dist[t] > dist[u] + w:
                dist[t] = dist[u] + w
                heapq.heappush(pq, (dist[t], t))
 
    return dist
연습 문제 (5)
문제출처난이도비고
이중 최단 경로 문제SWEA 15623D4
균형 잡힌 팀COJ 2328GOLD
합승 택시 요금프로그래머스Lv.3
우주 회의에 다녀오는 최단 항로COJ 2239MASTER
파티BOJ 1238Gold 3

벨만-포드

음수 간선이 있을 때 사용한다. N-1번 완화 후 한 번 더 완화되면 음수 사이클이 존재한다. 음수 사이클 감지가 목적이면 dist를 전부 0으로 초기화한다.

INF = float('inf')
dist = [0] * (n + 1)  # 음수 사이클 감지 목적: 0으로 초기화
# dist[start] = 0; dist 나머지 INF  ← 특정 출발점 최단거리가 목적이면
 
for _ in range(n - 1):
    for s, e, w in edge:
        if dist[e] > dist[s] + w:
            dist[e] = dist[s] + w
 
# 음수 사이클 감지
for s, e, w in edge:
    if dist[e] > dist[s] + w:
        print("음수 사이클 존재")
연습 문제 (4)
문제출처난이도비고
웜홀BOJ 1865Gold 3
타임머신BOJ 11657Gold 4
운동BOJ 1956Gold 4
오민식의 고민BOJ 1219Gold 2

17. 백트래킹

itertools는 모든 경우를 전부 생성한 뒤 필터링한다. "합이 100인 조합만"처럼 조건이 있으면 불필요한 경우까지 다 만들어서 느리다. 백트래킹은 만드는 도중에 조건에 안 맞으면 중단하고 다른 경우로 넘어간다.

뼈대는 항상 같다. "들어갈 때 표시 → 재귀 → 나올 때 되돌리기"가 대칭으로 움직인다.

순열 (permutation)

순서가 다르면 다른 경우로 취급할 때 쓴다. n개 중 r개를 뽑는 순서가 있는 배열이다. visit으로 이미 쓴 원소를 다시 선택하지 못하게 막는다.

n, r = map(int, input().split())
ans = []
visit = [0] * (n + 1)
 
def perm(idx):
    if idx == r + 1:
        print(ans)
        return
 
    for i in range(1, n + 1):
        if visit[i] == 0:
            ans.append(i)
            visit[i] = 1
            perm(idx + 1)
            visit[i] = 0
            ans.pop()
 
perm(1)

N-Queen 문제에서는 "각 행에 퀸을 하나씩 놓는 순열"로 볼 수 있는데, 여기서는 visit 배열 대신 is_safe로 "지금 놓으려는 자리가 이미 놓인 퀸들과 같은 열/대각선에 있는지"를 확인해서 가지치기한다. 안전할 때만 다음 행으로 내려가고(solve(row+1)), 되돌아올 땐 queens.pop()으로 방금 놓은 자리를 지운다.

n = int(input())
queens = []
count = 0
 
def is_safe(row, col):
    for r in range(row):
        c = queens[r]
        if col == c or abs(row - r) == abs(col - c): 
            return False
    return True
 
def solve(row):
    global count
    if row == n:
        count += 1
        return
    for col in range(n):
        if is_safe(row, col):
            queens.append(col)
            solve(row + 1)
            queens.pop()
 
solve(0)
print(count)
연습 문제 (9)
문제출처난이도비고
소수 찾기프로그래머스Lv.2
외벽 점검프로그래머스Lv.3
구간 없는 순열SWEAD2
순열의 아름다움SWEAD4
순열1SWEAD3
발굽, 보, 가위COJ 2476SILVER
같은 단어COJ 2453GOLD
N-QueenBOJ 9663Gold 4
스도쿠BOJ 2580Gold 1

중복순열 (repeated permutation)

같은 원소를 여러 번 써도 되는 경우다. visit 체크가 없고, ans[idx]를 덮어쓰는 방식이라 pop도 없다.

a, b = map(int, input().split())
ans = [0] * a
 
def perm(idx):
    if idx == a:
        print(ans)
        return
 
    for i in range(b):
        ans[idx] = i + 1
        perm(idx + 1)
 
perm(0)
연습 문제 (3)
문제출처난이도비고
모의고사프로그래머스Lv.1
숫자 야구프로그래머스Lv.2
N과 M (3)BOJ 15651Silver 3

조합 — 포함/미포함 방식

n개 원소를 순서대로 훑으면서 각각 넣을지 말지 결정한다. 순서가 고정되므로 visit 없이도 중복이 막힌다. "원소 수가 고정되어 있고 조건을 만족하는 부분집합을 찾는" 문제에 쓴다.

arr = [...]
n = len(arr)
visit = [0] * n
 
def comb(idx):
    if idx == n:
        # visit[i] == 1인 원소들이 선택된 조합
        return
 
    visit[idx] = 1
    comb(idx + 1)   # 포함
    visit[idx] = 0
    comb(idx + 1)   # 미포함
 
comb(0)

타겟 넘버 문제에서는 이 패턴을 그대로 쓴다. 각 숫자마다 "더한다 / 뺀다"라는 두 갈래로 나뉘니, visit 배열 대신 "지금까지의 합"을 그대로 다음 재귀에 넘기는 방식으로 바꿨다. 배열을 직접 건드리지 않고 매번 새로운 값을 계산해서 넘기기 때문에, 되돌리는 코드(visit[idx] = 0 같은) 없이도 재귀가 끝나면 자동으로 다음 갈래로 넘어간다.

def solution(numbers, target):
    answer = 0
 
    def dfs(index, total):
        nonlocal answer 
        if index == len(numbers):
            if total == target:
                answer += 1
            return
        dfs(index + 1, total + numbers[index]) 
        dfs(index + 1, total - numbers[index]) 
 
    dfs(0, 0)
    return answer
연습 문제 (6)
문제출처난이도비고
타겟 넘버프로그래머스Lv.2
소수 만들기프로그래머스Lv.1
태양이의 캠프 조합SWEAD6
조합의 약수의 개수SWEAD5
암호 만들기BOJ 1759Gold 5
차이를 최대로BOJ 10819Silver 2

18. 이진 탐색

기본 템플릿

정렬된 배열에서 특정 값이 있는지, 또는 몇 번째 위치에 있는지 찾아야 할 때 쓴다. O(logn)O(\log n)이라 원소가 수백만 개여도 빠르다. left와 right를 좁혀가며 mid를 비교한다.

def binary_search(arr, target):
    left, right = 0, len(arr) - 1
 
    while left <= right:
        mid = (left + right) // 2
 
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
 
    return -1

값이 있는 가장 왼쪽 위치

target 이상인 첫 번째 위치를 찾는다. bisect_left와 동일하다.

def binary_search_left(arr, target):
    left, right = 0, len(arr)
 
    while left < right:
        mid = (left + right) // 2
 
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid
 
    return left

값이 있는 가장 오른쪽 위치

target 이하인 마지막 위치를 찾는다. bisect_right에서 1을 뺀 값과 동일하다.

def binary_search_right(arr, target):
    left, right = 0, len(arr)
 
    while left < right:
        mid = (left + right) // 2
 
        if arr[mid] <= target:
            left = mid + 1
        else:
            right = mid
 
    return left - 1
연습 문제 (4)
문제출처난이도비고
이진탐색정올 3517입문
이진탐색 (라이브러리)정올 8069입문
수 찾기BOJ 1920Silver 4
숫자 카드 2BOJ 10816Silver 4

답이 범위에 있는 경우 (파라메트릭 서치)

답 자체가 어떤 범위 안에 있고, 특정 값이 조건을 만족하는지 O(n)O(n) 이하로 검증할 수 있으면 이진 탐색으로 O(nlogn)O(n \log n)에 풀 수 있다. 조건을 만족하는 가장 작은 값을 찾는 패턴이다.

def can_achieve(x):
    # x로 조건을 만족할 수 있는지 확인
    return True or False
 
low, high = 0, 10**9
ans = 0
 
while low <= high:
    mid = (low + high) // 2
 
    if can_achieve(mid):
        ans = mid
        high = mid - 1  # 더 작게 시도
    else:
        low = mid + 1
 
print(ans)

mid가 조건을 만족했다는 건 "더 작은 값 중에도 답이 있을 수 있으니 왼쪽을 계속 살펴봐야 한다"는 뜻이다. mid 자신은 이미 확인했으니 다시 볼 필요가 없어서, 다음 탐색 범위는 high = mid - 1로 그보다 작은 쪽만 남긴다. 반대로 mid가 조건을 만족하지 못했다면 더 큰 값이 필요하므로 low = mid + 1로 그보다 큰 쪽만 남긴다. (예: 퍼즐 게임 챌린지) 이 문제에서 can_achieve(level)은 "이 숙련도로 모든 퍼즐을 풀 때 걸리는 시간을 계산해서 limit 이하인지" 확인하는 함수가 된다. 탐색 범위의 high는 무조건 조건을 만족하는 게 보장된 값(예: max(diffs), 이 값 이상이면 항상 틀리지 않으므로)으로 잡고, low는 조건이 성립할 수 있는 최솟값(보통 1)으로 잡는다.

퍼즐 게임 챌린지 문제에서는 level을 직접 계산하는 공식이 없어서, "이 level이면 시간 안에 끝나는가"를 판정하는 calc_time 함수를 먼저 만들고 그 위에 파라메트릭 서치를 얹었다.

def calc_time(level, diffs, times):
    ttime = 0
    for i in range(len(diffs)):
        if diffs[i] <= level:
            ttime += times[i]
        else:
            ttime += (diffs[i] - level) * (times[i] + times[i - 1]) + times[i]
    return ttime
 
def solution(diffs, times, limit):
    low, high = 1, max(diffs)
    answer = high
 
    while low <= high:
        mid = (low + high) // 2
        if calc_time(mid, diffs, times) <= limit: 
            answer = mid 
            high = mid - 1
        else:
            low = mid + 1
 
    return answer

최솟값을 최대화 / 최댓값을 최대화

파라메트릭 서치의 반대 방향이다. 두 소 사이 최소 거리를 최대화하거나, 절단 높이를 최대화하는 것처럼 가능한 한 크게 만들어야 할 때 쓴다. 조건을 만족하는 가장 큰 값을 찾는다.

def can_achieve(x):
    # x로 조건을 만족할 수 있는지 확인
    return True or False
 
low, high = 0, 10**9
ans = 0
 
while low <= high:
    mid = (low + high) // 2
 
    if can_achieve(mid):
        ans = mid
        low = mid + 1   # 더 크게 시도
    else:
        high = mid - 1
 
print(ans)

두 패턴의 차이는 조건 만족 시 low를 올리냐 high를 내리냐뿐이다. 가장 큰 값을 찾으면 low = mid + 1, 가장 작은 값을 찾으면 high = mid - 1.

기지국 설치에는 사실 이진 탐색을 안 쓰고 그리디로 풀었지만, "커버리지가 미치지 않는 구간의 길이를 계산해서 필요한 기지국 수를 올림 나눗셈으로 구한다"는 계산 자체가 이 패턴의 핵심 아이디어(구간을 정해진 크기로 나눠 몇 개가 필요한지 세는 것)와 같다.

def solution(n, stations, w):
    answer = 0
    coverage = 2 * w + 1
    start = 1
 
    for station in stations:
        left, right = station - w, station + w
        if start < left:
            length = left - start
            answer += (length + coverage - 1) // coverage 
        start = right + 1
 
    if start <= n:
        length = n - start + 1
        answer += (length + coverage - 1) // coverage 
 
    return answer
연습 문제 (11)
문제출처난이도비고
퍼즐 게임 챌린지프로그래머스Lv.2
입국심사프로그래머스Lv.3
징검다리 건너기프로그래머스Lv.3
선입 선출 스케줄링프로그래머스Lv.3
이진탐색 응용정올 8654입문
기지국 설치프로그래머스Lv.3
소들의 야구COJ 2318SILVER
건초더미 세기COJ 2464GOLD
나무 자르기BOJ 2805Silver 2
징검다리프로그래머스Lv.4
K번째 수BOJ 1300Gold 1

19. Union-Find (Disjoint Set Union)

두 원소가 같은 그룹에 속하는지 확인하거나, 그룹을 합치는 연산이 반복될 때 쓴다. 연결된 컴포넌트 개수를 세거나, 사이클 존재 여부를 판별하는 문제에 자주 등장한다. DFS로 연결 여부를 확인하면 쿼리마다 O(n)O(n)이지만, Union-Find를 쓰면 거의 O(1)O(1)에 처리할 수 있다.

기본 구조

find로 루트를 찾고, union으로 두 집합을 합친다. (예: 집합의 표현) find를 호출할 때 거쳐간 노드들을 루트에 직접 연결하는 것을 경로 압축이라 하고, 다음 find가 O(1)O(1)에 가까워진다.

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
 
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # 경로 압축
        return self.parent[x]
 
    def union(self, x, y):
        px, py = self.find(x), self.find(y)
 
        if px == py:
            return
 
        # 랭크가 낮은 트리를 높은 트리 아래로
        if self.rank[px] < self.rank[py]:
            px, py = py, px
 
        self.parent[py] = px
 
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
 
    def is_connected(self, x, y):
        return self.find(x) == self.find(y)

사용 예제

union으로 합치고, is_connected로 같은 집합인지 확인한다.

uf = UnionFind(5)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 2)
 
print(uf.is_connected(0, 3))  # True (연결됨)
print(uf.is_connected(0, 4))  # False

함수 기반 템플릿

코테에서는 클래스 없이 함수로 짧게 쓰는 경우가 많다. 경로 압축만 있어도 대부분의 문제는 충분히 빠르다.

import sys
sys.setrecursionlimit(10**5 + 5)
 
p = list(range(n + 1))
 
def find(x):
    if x != p[x]:              # 루트가 아니면 위로 올라간다
        p[x] = find(p[x])      # 경로 압축
    return p[x]
 
def union(x, y):
    rx, ry = find(x), find(y)
    p[rx] = ry

p = list(range(n + 1))은 처음엔 모든 원소가 각자 독립된 그룹이라는 뜻이다. p[3] = 3이면 3번은 아직 아무와도 합쳐지지 않은, 자기 그룹의 대표(루트)라는 의미다.

find(x)x가 속한 그룹의 대표를 찾는다. x != p[x]라는 건 x가 가리키는 부모가 자기 자신이 아니라는 뜻이므로, 아직 루트에 도달하지 못했다는 신호다. 이럴 땐 부모를 따라 재귀적으로 더 올라가야 한다. 반대로 x == p[x]면 자기 자신을 가리키고 있는 것이니 이게 곧 루트다. p[x] = find(p[x])는 경로 압축이다. 재귀로 찾아낸 루트를 x의 부모 자리에 바로 덮어써서, 다음번 find(x) 호출 때는 예전 경로를 다시 타지 않고 한 번에 루트로 갈 수 있게 만든다.

union(x, y)xy 자체가 아니라 각자의 루트(rx, ry)를 구해서 합친다. xy가 이미 다른 원소와 합쳐진 상태라면 자기 자신이 루트가 아닐 수 있기 때문에, 합쳐야 하는 대상은 원소 하나가 아니라 그 원소가 속한 그룹 전체다. p[rx] = ryrx 그룹의 대표가 ry 그룹의 대표를 가리키게 만들어서 두 그룹을 하나로 합친다.

연습 문제 (8)
문제출처난이도비고
행성 IDCOJ 2452SILVER
소속 찾기COJ 2459GOLD
집합의 표현BOJ 1717Gold 4
여행 가자BOJ 1976Gold 4
사이클 게임BOJ 20040Gold 4
농장 폐쇄(GOLD)COJ 2447EMERALD
소 연합COJ 2371EMERALD
트랙터COJ 2267EMERALD

크루스칼 MST

Union-Find의 대표 활용처다. 간선을 가중치 오름차순으로 정렬하고, 사이클이 생기지 않는(루트가 다른) 간선만 채택하며 비용을 누적한다.

edges.sort()  # (가중치, a, b)
 
ans = 0
for w, a, b in edges:
    if find(a) != find(b):
        union(a, b)
        ans += w

섬 연결하기 문제에서는 costs[a, b, cost] 순서로 주어져서, find/union은 그대로 두고 정렬 기준(key=lambda x: x[2])만 세 번째 값으로 맞췄다. find/unionsolution 안에 중첩 함수로 넣어서 p 리스트를 따로 전역/글로벌 처리 없이 공유한다.

def solution(n, costs):
    p = list(range(n))
 
    def find(x):
        if x != p[x]:
            p[x] = find(p[x])
        return p[x]
 
    def union(x, y):
        rx, ry = find(x), find(y)
        p[rx] = ry
 
    answer = 0
    costs.sort(key=lambda x: x[2]) 
    for a, b, w in costs:
        if find(a) != find(b): 
            union(a, b) 
            answer += w 
 
    return answer
연습 문제 (5)
문제출처난이도비고
섬 연결하기프로그래머스Lv.3
최소 스패닝 트리BOJ 1197Gold 4
울타리 치기(PLATINUM)COJ 2438EMERALD
슈퍼불COJ 2387EMERALD
농장 단순화COJ 2195EMERALD

오프라인 쿼리: 제거를 역순으로 뒤집기

Union-Find는 합치기만 할 수 있고 분리는 못 한다. 그래서 "정점(또는 간선)을 하나씩 제거하면서 매번 연결 상태를 확인하라"는 문제는 그대로는 풀 수 없다.

그렇다면 어떻게 할까? 쿼리를 전부 읽어둔 뒤 역순으로 처리하면 된다. 마지막 상태(다 제거된 상태)에서 시작해서 거꾸로 가면 "제거"가 "추가"로 바뀌고, 추가는 union으로 처리할 수 있다. 답을 역순으로 쌓았다가 마지막에 뒤집어 출력한다.

이렇게 쿼리를 온 순서대로 즉시 답하지 않고, 모아서 유리한 순서로 재배열해 처리하는 기법을 오프라인 쿼리라 한다.

order = [...]  # 제거 순서
order.reverse()
 
live = [0] * (n + 1)
ans = []
con = 0  # 유효한 union 횟수
 
for i, v in enumerate(order):
    live[v] = 1
    for u in graph[v]:
        if live[u] and find(v) != find(u):
            union(v, u)
            con += 1
    # 살아있는 정점 i+1개가 한 컴포넌트려면 union이 정확히 i번이어야 한다
    ans.append('YES' if i == con else 'NO')
 
ans.reverse()
print('\n'.join(ans))

연결성 판정에 트리의 성질을 쓴다. 정점 k개가 하나의 컴포넌트를 이루려면 서로 다른 집합을 합친 횟수가 정확히 k-1이어야 한다. 컴포넌트 개수를 따로 세지 않고 union 카운트만으로 판정할 수 있다.

연습 문제 (3)
문제출처난이도비고
트리BOJ 13306Platinum 5
선물COJ 2274DIAMOND
백설공주와 N명의 난쟁이COJ 2272DIAMOND

20. 그리디

그리디는 매 순간 최선의 선택을 반복해서 전체 최적해를 구하는 기법이다. 단, "매 순간 최선 = 전체 최선"이 성립하는 문제에서만 쓸 수 있다. 예를 들어 동전 거스름돈에서 큰 동전부터 쓰는 건 그리디가 맞지만, 배낭 문제에서 비싼 것부터 넣으면 최적이 아닐 수 있다.

정렬 후 처리 패턴

가장 크거나 작은 것부터 처리하면 최적이 되는 문제에 쓴다. 정렬 후 순서대로 결정을 내려가는 방식이다.

# 동전 거스름돈 (가장 큰 동전부터)
coins = [100, 50, 10, 1]
amount = 237
count = 0
 
for coin in coins:
    count += amount // coin
    amount %= coin
 
print(count)  # 최소 동전 개수

구명보트 문제에서는 정렬해두고 양 끝에서 좁혀오는 투 포인터랑 그리디를 합친 형태다. 가장 무거운 사람(end)과 가장 가벼운 사람(start)을 같이 태울 수 있으면 같이 태우고, 안 되면 무거운 사람만 혼자 태운다. 어느 쪽이든 보트 한 척은 무조건 쓰니 answer는 매번 늘어난다.

def solution(people, limit):
    answer = 0
    people.sort()
    start, end = 0, len(people) - 1
 
    while start <= end:
        if people[start] + people[end] <= limit: 
            start += 1
        end -= 1
        answer += 1
 
    return answer
연습 문제 (10)
문제출처난이도비고
체육복프로그래머스Lv.1
서툰 소들COJ 2235GOLD
그리디 알고리즘정올 3521입문
그리디 증명 방법정올 8581입문
조이스틱프로그래머스Lv.2
큰 수 만들기프로그래머스Lv.2
구명보트프로그래머스Lv.2
동전 0BOJ 11047Silver 1
로프BOJ 2217Silver 4
잃어버린 괄호BOJ 1541Silver 2

겹치는 예외를 먼저 제거하고 집계하기

두 그룹(예: "도난당한 사람"과 "여벌이 있는 사람") 사이에 겹치는 원소가 있고, 그 겹치는 원소는 "이미 스스로 해결됐다"고 처리해야 할 때 쓰는 패턴이다. (예: 체육복)

lost_set = set(lost)
reserve_set = set(reserve)
 
overlap = lost_set & reserve_set      # 양쪽에 다 있는 사람 (자기 여벌로 자기가 해결)
lost_set -= overlap
reserve_set -= overlap
 
answer = n - len(lost_set)            # 겹치는 사람을 뺀 뒤에 기준값을 계산해야 한다
 
for x in sorted(lost_set):
    if (x - 1) in reserve_set:
        reserve_set.remove(x - 1)
        answer += 1
    elif (x + 1) in reserve_set:
        reserve_set.remove(x + 1)
        answer += 1

answer = n - len(lost_set)겹치는 사람을 빼기 전의 lost_set으로 계산하면, 겹치는 사람은 "도난당한 사람으로 카운트되어 answer에서 깎이는데, 동시에 lost_set에서도 빠져서 나중에 구제될 기회도 없어지는" 이중 손해를 본다. 겹치는 원소를 양쪽 집합에서 먼저 제거한 뒤에 기준값을 계산해야, 그 원소가 "이미 스스로 해결된 사람"으로 정확히 카운트된다.

체육복 문제에서는 이 패턴을 그대로 쓴다. 겹치는 학생을 먼저 걸러내고, 순서를 지켜서 answer 기준값을 계산한 부분이 핵심이다.

def solution(n, lost, reserve):
    ls = set(lost)
    rs = set(reserve)
 
    os = ls & rs
    ls = ls - os
    rs = rs - os
 
    answer = n - len(ls) 
 
    for x in sorted(ls):
        if (x - 1) in rs:
            rs.remove(x - 1)
            answer += 1
        elif (x + 1) in rs:
            rs.remove(x + 1)
            answer += 1
 
    return answer

구간 스케줄링 패턴

겹치지 않는 구간을 최대한 많이 선택해야 할 때 쓴다. 끝나는 시간 기준으로 정렬한 뒤, 이전 구간이 끝난 후 시작하는 것만 선택하면 된다. 왜 끝 시간 기준인지가 핵심인데, 일찍 끝나는 것을 먼저 선택해야 다음 구간을 위한 여유가 최대로 남기 때문이다.

meetings = [(1, 3), (2, 5), (4, 6), (6, 7)]
# (시작 시간, 끝나는 시간)
 
meetings.sort(key=lambda x: x[1])  # 끝나는 시간 기준
 
count = 0
last_end = 0
 
for start, end in meetings:
    if start >= last_end:
        count += 1
        last_end = end
 
print(count)  # 최대 회의 개수
연습 문제 (2)
문제출처난이도비고
단속카메라프로그래머스Lv.3
회의실 배정BOJ 1931Silver 1

21. 동적 프로그래밍 (DP)

같은 계산이 반복되고, 큰 문제의 답이 작은 문제의 답으로 구성될 때 DP를 쓴다. 재귀로 풀었을 때 같은 인수로 여러 번 호출된다면 DP로 최적화할 수 있다는 신호다. 점화식을 먼저 세우고, 그 점화식을 코드로 옮기는 순서로 접근한다.

탑다운 vs 바텀업

DP를 구현하는 두 가지 방식이 있다.

바텀업 (Bottom-Up): 작은 문제부터 for문으로 채워 올라간다. 재귀 제한이 없고 직관적이다.

# 피보나치 - 바텀업
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
    dp[i] = dp[i-1] + dp[i-2]

탑다운 (Top-Down): 큰 문제에서 시작해 재귀로 작은 문제를 호출한다. lru_cache나 memo 딕셔너리로 중복 계산을 방지한다.

# 피보나치 - 탑다운
from functools import lru_cache
 
@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

땅따먹기 문제에서는 바텀업으로 접근한다. 한 줄 아래로 내려갈 때, "바로 위 칸을 제외한 나머지 세 칸 중 최댓값"을 더한다. 같은 열을 연속으로 밟을 수 없다는 규칙이 land[i-1][:j] + land[i-1][j+1:]로 자연스럽게 반영된다.

def solution(land):
    for i in range(1, len(land)):
        for j in range(len(land[0])):
            land[i][j] += max(land[i - 1][:j] + land[i - 1][j + 1:]) 
 
    return max(land[-1])
바텀업탑다운
구현for문 + dp 배열재귀 + 메모이제이션
방향작은 문제 → 큰 문제큰 문제 → 작은 문제
장점재귀 제한 없음필요한 것만 계산
단점불필요한 것도 계산할 수 있음파이썬 재귀 깊이 제한

코테에서는 바텀업을 더 자주 사용한다. 재귀 제한 걱정이 없고 속도도 빠르다.

연습 문제 (11)
문제출처난이도비고
정수 삼각형프로그래머스Lv.3
소 사방치기COJ 2384GOLD
동적계획법정올 3522입문
땅따먹기프로그래머스Lv.2
멀리 뛰기프로그래머스Lv.2
N으로 표현프로그래머스Lv.3
등굣길프로그래머스Lv.3
도둑질프로그래머스Lv.3
1로 만들기BOJ 1463Silver 3
RGB거리BOJ 1149Silver 1
LCSBOJ 9251Gold 5

격자형 DP (삼각형/경로 문제)

삼각형이나 격자를 위에서 아래로 내려가며 최댓값(또는 최솟값) 경로를 구하는 유형이다. dp[r][c]는 "그 칸까지 오는 가장 좋은 누적값"이고, 바로 위 줄의 대각선 칸들로부터 갱신된다. (예: 정수 삼각형)

dp = [row[:] for row in triangle]
n = len(triangle)
 
for r in range(1, n):
    for c in range(len(triangle[r])):
        if c == 0:
            dp[r][c] = triangle[r][c] + dp[r - 1][c]
        elif c == r:
            dp[r][c] = triangle[r][c] + dp[r - 1][c - 1]
        else:
            dp[r][c] = triangle[r][c] + max(dp[r - 1][c - 1], dp[r - 1][c])
 
print(max(dp[-1]))

세 갈래로 나누는 이유는 그 줄의 양 끝에서는 위 줄의 대각선 칸이 하나가 빠지기 때문이다. r번째 줄은 인덱스 0부터 r까지 있고, 바로 위(r-1번째) 줄은 0부터 r-1까지만 있다. 그래서 맨 왼쪽 칸(c == 0)은 왼쪽 위 대각선(dp[r-1][c-1], 즉 dp[r-1][-1])이 존재하지 않고 — 파이썬에서 -1은 에러 없이 "맨 뒤 인덱스"로 조용히 해석되어 버리므로 이 실수는 눈에 잘 안 띈다 — 오른쪽 위 대각선만 써야 한다. 반대로 맨 오른쪽 칸(c == r)은 위 줄에 c번 인덱스 자체가 없으므로(IndexError) 왼쪽 위 대각선만 써야 한다. 그 사이 칸들만 두 대각선 중 큰 값을 고르면 된다.

정수 삼각형 문제에서는 triangle을 그대로 복사해서 dp로 쓰고, 한 줄씩 내려가면서 그 줄의 각 칸이 맨 왼쪽/맨 오른쪽/중간 중 어디인지에 따라 위에서 짚은 세 갈래로 갱신한 뒤, 마지막 줄에서 가장 큰 값을 반환한다.

def solution(triangle):
    n = len(triangle)
    dp = [row[:] for row in triangle]
 
    for r in range(1, n):
        for c in range(len(triangle[r])):
            if c == 0: 
                dp[r][c] = triangle[r][c] + dp[r - 1][c] 
            elif c == r: 
                dp[r][c] = triangle[r][c] + dp[r - 1][c - 1] 
            else: 
                dp[r][c] = triangle[r][c] + max(dp[r - 1][c - 1], dp[r - 1][c]) 
 
    return max(dp[-1])
연습 문제 (4)
문제출처난이도비고
정수 삼각형프로그래머스Lv.3
등굣길프로그래머스Lv.3
정수 삼각형BOJ 1932Silver 1
내려가기BOJ 2096Gold 5

0/1 배낭 (Knapsack)

n개의 물건 중 무게 W를 초과하지 않으면서 최대 가치를 담는 문제다. (예: 평범한 배낭)

dp[i][j]는 처음 i개 물건 중에서 무게 j 이하로 담을 때의 최대 가치다. 각 물건마다 "안 담기 vs 담기" 중 큰 값을 선택한다.

def knapsack(n, W, weights, values):
    dp = [[0] * (W + 1) for _ in range(n + 1)]
 
    for i in range(1, n + 1):
        w, v = weights[i-1], values[i-1]
 
        for j in range(W + 1):
            dp[i][j] = dp[i-1][j]                        # 안 담기
            if j >= w:
                dp[i][j] = max(dp[i][j], dp[i-1][j-w] + v)  # 담기
 
    return dp[n][W]

2D 배열 대신 1D 배열로 공간을 절약할 수 있다. 역순으로 순회해야 같은 물건을 중복으로 담는 것을 방지한다.

def knapsack_1d(n, W, weights, values):
    dp = [0] * (W + 1)
 
    for i in range(n):
        w, v = weights[i], values[i]
        for j in range(W, w - 1, -1):    # 역순 순회
            dp[j] = max(dp[j], dp[j-w] + v)
 
    return dp[W]

평범한 배낭 문제에서는 위 2D 템플릿을 거의 그대로 가져다 썼다.

n, k = map(int, input().split())
items = [tuple(map(int, input().split())) for _ in range(n)]
 
dp = [[0] * (k + 1) for _ in range(n + 1)]
 
for i in range(1, n + 1):
    w, v = items[i - 1]
    for j in range(k + 1):
        if j >= w:
            dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w] + v) 
        else:
            dp[i][j] = dp[i - 1][j]
 
print(dp[n][k])
연습 문제 (6)
문제출처난이도비고
평범한 배낭BOJ 12865Gold 5
BOJ 7579Gold 3
금광프로그래머스Lv.3
무우 무우COJ 2346PLATINUM
보리볏 나누기COJ 2202PLATINUM
타일 교환COJ 2168PLATINUM

22. 세그먼트 트리

배열 원소가 자주 바뀌면서 구간 합도 자주 구해야 할 때 쓴다. 구간합만 필요하고 값이 안 바뀌면 prefix sum으로 충분하지만, 값이 중간에 바뀌면 prefix sum은 재계산에 O(n)O(n)이 든다. 세그먼트 트리는 둘 다 O(logn)O(\log n)에 처리한다.

연산Prefix Sum세그먼트 트리
구간합 구하기O(1)O(1)O(logn)O(\log n)
값 변경O(n)O(n)O(logn)O(\log n)

개념

배열을 완전 이진 트리로 표현한다. tree[i]의 왼쪽 자식은 tree[2*i], 오른쪽 자식은 tree[2*i+1]이다. 리프 노드에는 원본 배열의 원소가 들어가고, 내부 노드에는 자식들의 합이 저장된다.

전체 템플릿

build로 초기 트리를 구성하고 (예: 구간 합 구하기), update로 값을 수정하며, query로 구간 합을 구한다.

tree = [0] * (4 * n)
 
def build(node, start, end):
    if start == end:
        tree[node] = arr[start]
    else:
        mid = (start + end) // 2
        build(2 * node, start, mid)
        build(2 * node + 1, mid + 1, end)
        tree[node] = tree[2 * node] + tree[2 * node + 1]
 
def update(node, start, end, idx, val):
    if start == end:
        tree[node] = val
    else:
        mid = (start + end) // 2
        if idx <= mid:
            update(2 * node, start, mid, idx, val)
        else:
            update(2 * node + 1, mid + 1, end, idx, val)
        tree[node] = tree[2 * node] + tree[2 * node + 1]
 
def query(node, start, end, l, r):
    if r < start or end < l:
        return 0
    if l <= start and end <= r:
        return tree[node]
    mid = (start + end) // 2
    return query(2 * node, start, mid, l, r) + query(2 * node + 1, mid + 1, end, l, r)
 
# 사용
build(1, 0, n - 1)                        # 트리 생성
update(1, 0, n - 1, idx, val)             # 값 변경
print(query(1, 0, n - 1, l, r))           # 구간 합
연습 문제 (7)
문제출처난이도비고
구간 합 구하기BOJ 2042Gold 1
구간 곱 구하기BOJ 11505Gold 1
최솟값과 최댓값BOJ 2357Gold 1
최솟값BOJ 10868Gold 1
버블 소트BOJ 1517Gold 1
달리기BOJ 2517Gold 1
사탕상자BOJ 2243Gold 1

23. 구현 / 시뮬레이션

특별한 알고리즘이 떠오르지 않을 때, 문제 설명을 그대로 코드로 옮기면 되는 유형이다. 복잡한 조건 분기와 경계 처리가 핵심이라 틀리기 쉽다. 격자 이동, 방향 전환, 상태 변화를 단계별로 시뮬레이션하는 문제가 많다. 코드를 짜기 전에 흐름을 손으로 한 번 따라가보는 게 실수를 줄이는 데 도움이 된다.

주차 요금 계산 문제에서는 입/출차 기록을 순서대로 훑으면서 딕셔너리 두 개(입차 시각, 누적 시간)로 상태를 관리하고, 끝까지 출차 안 한 차는 마지막에 따로 처리한다.

from collections import defaultdict
import math
 
def solution(fees, records):
    intime = {}
    tottime = defaultdict(int)
 
    for r in records:
        time, car, action = r.split(" ")
        hh, mm = time.split(":")
        t = int(hh) * 60 + int(mm)
 
        if action == "IN":
            intime[car] = t
        elif action == "OUT": 
            tottime[car] += t - intime[car] 
            del intime[car] 
 
    for ic in intime: 
        tottime[ic] += 1439 - intime[ic] 
 
    answer = []
    cars = sorted(tottime.keys())
    for t in cars:
        if tottime[t] <= fees[0]:
            answer.append(fees[1])
        else:
            answer.append(fees[1] + math.ceil((tottime[t] - fees[0]) / fees[2]) * fees[3])
 
    return answer
연습 문제 (49)
문제출처난이도비고
크레인 인형뽑기 게임프로그래머스Lv.1
키패드 누르기프로그래머스Lv.1
신규 아이디 추천프로그래머스Lv.1
캐릭터의 좌표프로그래머스Lv.1
카카오프렌즈 컬러링북프로그래머스Lv.2
행렬 테두리 회전하기프로그래머스Lv.2
주차 요금 계산프로그래머스Lv.2
롤케이크 자르기프로그래머스Lv.2
두 큐 합 같게 만들기프로그래머스Lv.2
괄호 회전하기프로그래머스Lv.2
방문 길이프로그래머스Lv.2
이모티콘 할인행사프로그래머스Lv.2
숫자 변환하기프로그래머스Lv.2
뉴스 클러스터링프로그래머스Lv.2
문자열 압축프로그래머스Lv.2
후보키프로그래머스Lv.2
순위 검색프로그래머스Lv.2
메뉴 리뉴얼프로그래머스Lv.2
셔틀버스프로그래머스Lv.3
표 편집프로그래머스Lv.3
소-신호COJ 2463SILVER
블록 게임COJ 2462SILVER
화난 소(BRONZE)COJ 2416GOLD
상한 우유COJ 2402GOLD
과속 딱지COJ 2401GOLD
울타리 칠하기COJ 2400SILVER
건초더미에 갇히다(BRONZE)COJ 2393GOLD
소 길찾기 IICOJ 2373GOLD
소 길찾기COJ 2372SILVER
십자말풀이COJ 2360GOLD
마라톤(BRONZE)COJ 2359GOLD
소 재배치COJ 2341GOLD
만나서 반가워COJ 2240SILVER
줄지어 선 소들COJ 2222GOLD
농장 탈출COJ 2191GOLD
Moo SickCOJ 2165SILVER
품종 근접성COJ 2288SILVER
소들의 경주COJ 2287SILVER
동아리 부원 모집하기COJ 2278SILVER
밧줄 접기COJ 2204GOLD
울트라 369COJ 2279GOLD
로봇 청소기BOJ 14503Gold 5
BOJ 3190Gold 4
톱니바퀴BOJ 14891Gold 5
드래곤 커브BOJ 15685Gold 3
주사위 굴리기BOJ 14499Gold 5
감시BOJ 15683Gold 4
치킨 배달BOJ 15686Gold 5
미세먼지 안녕!BOJ 17144Gold 1

부록 1. 시간초과 대처

sys.stdin.readline 사용

input()보다 빠르다. 반복 입력이 많을 때 사용한다.

import sys
input = sys.stdin.readline
 
n = int(input())
for _ in range(n):
    line = input().split()

sys.setrecursionlimit

파이썬의 기본 재귀 제한은 1000이다. 깊은 재귀가 필요하면 늘려야 한다.

import sys
sys.setrecursionlimit(10**6)

PyPy3로 제출

같은 코드를 PyPy3로 제출하면 보통 2-3배 빠르다. 대부분의 온라인 저지에서 지원한다.

print()를 반복 호출하면 느리다. join으로 모아서 한 번에 출력한다.

# X: 느림
for i in range(10000):
    print(i)
 
# O: 빠름
result = [str(i) for i in range(10000)]
print('\n'.join(result))

부록 2. 부동소수점 주의

정수 우선 계산

비교 연산은 나누기 대신 곱셈으로 변환해서 부동소수점 오차를 피한다.

# X: 부동소수점 오차
if a / b < c / d:
    pass
 
# O: 정수 연산
if a * d < c * b:
    pass

나누기는 마지막에

정수로 할 수 있는 계산은 먼저 정수로 처리하고, 나누기는 마지막 단계에서만 수행한다.

# 분산 계산
# X: (1/n * sum(x^2)) - (1/n * sum(x))^2
# O: (sum(x^2) / n) - (sum(x) / n)^2
# 더 좋음: (n * sum(x^2) - (sum(x))^2) / n^2
 
n = 100
sum_x = 5000
sum_x2 = 250000
 
variance = (n * sum_x2 - sum_x * sum_x) / (n * n)

부록 3. 파일 입출력 (Output-Only 문제)

일부 문제는 표준 입출력 대신 파일 입출력을 요구한다.

파일 읽기/쓰기

with문으로 파일을 열고, readlines()로 전체를 읽거나 write()로 결과를 쓴다.

# 읽기
with open('input.txt', 'r') as f:
    lines = f.readlines()
    for line in lines:
        line = line.strip()
 
# 또는
with open('input.txt', 'r') as f:
    data = f.read()
 
# 쓰기
with open('output.txt', 'w') as f:
    f.write('result\n')
    f.write('answer\n')

한 줄씩 읽기

readline()을 반복 호출해서 한 줄씩 처리한다.

with open('input.txt', 'r') as f:
    n = int(f.readline())
 
    for _ in range(n):
        line = f.readline().strip()
        # 처리

부록 4. 자주 하는 실수

1. 리스트 초기화 실수

[[0]*m]*n은 모든 행이 같은 객체를 참조해서 한 행을 수정하면 나머지 행도 바뀐다.

# X: 모든 행이 같은 객체를 참조
grid = [[0] * 5] * 3
grid[0][0] = 1
print(grid[1][0])  # 1 (예상: 0)
 
# O
grid = [[0] * 5 for _ in range(3)]

2. sort() 반환값 오해

sort()는 in-place로 정렬하고 None을 반환한다. 정렬된 새 리스트가 필요하면 sorted()를 쓴다.

arr = [3, 1, 2]
 
# X: sort()의 반환값은 None
result = arr.sort()
print(result)  # None
 
# O: sorted()는 새 리스트를 반환
result = sorted(arr)
print(result)  # [1, 2, 3]

sort()None을 반환하는 건 실수가 아니라 파이썬의 일관된 규칙이다. list.append(), list.reverse(), dict.update()처럼 원본을 직접 바꾸는(in-place) 메서드는 전부 None을 반환한다. "값을 바꿨다"와 "새 값을 돌려줬다"를 동시에 하면 헷갈리기 쉬우니, 파이썬은 아예 "제자리에서 바꾸는 놈은 반환값이 없다"로 못박아 둔 것이다. 그래서 arr.sort()를 실행하면 arr 자체가 정렬된 상태로 바뀌고, 그 호출의 결과값은 그냥 버려지는 None이다.

두 방식의 차이는 세 가지로 정리된다.

sort()sorted()
원본그 자리에서 바뀜그대로 유지됨
반환값None정렬된 새 리스트
사용 대상리스트만 (list의 메서드)모든 iterable (튜플, 문자열, 딕셔너리, 제너레이터 등)

원본을 그대로 유지해야 하는 상황(예: "원래 순서"랑 "정렬된 순서"를 둘 다 나중에 써야 할 때)에서 실수로 arr.sort()를 쓰면, 그 순간 원본 arr가 영구히 바뀌어버려서 되돌릴 수 없다. 반대로 튜플이나 문자열처럼 애초에 .sort() 메서드 자체가 없는 자료형은 sorted()만 쓸 수 있다.

t = (3, 1, 2)
t.sort()          # AttributeError: 튜플은 sort() 메서드가 없다
sorted(t)          # [1, 2, 3] — sorted()는 어떤 iterable이든 받아서 리스트로 반환한다

result = arr.sort()처럼 반환값을 변수에 담아 쓰려는 코드는 항상 버그다. arr.sort()는 이미 arr 자체를 바꿔놨으니, 그냥 그 다음 줄에서 arr를 쓰면 된다.

3. divmod 활용

몫과 나머지를 동시에 구할 때는 divmod()로 한 줄로 쓴다.

quotient, remainder = divmod(a, b)

4. sorted(dict)가 딕셔너리를 리스트로 바꿔버림

sorted(딕셔너리)는 키만 정렬된 리스트를 반환한다. 이 결과를 원래 딕셔너리 변수에 그대로 덮어쓰면, 그 시점부터 값(value)에 접근할 방법이 사라진다.

tottime = {"0000": 334, "5961": 146}
 
# X: tottime 자체가 정렬된 키 리스트로 바뀌어버림
tottime = sorted(tottime)
tottime["0000"]   # TypeError: list indices must be integers or slices, not str
 
# O: 정렬된 키는 별도 변수에 담고, 원본 딕셔너리는 그대로 둔다
car_list = sorted(tottime.keys())   # sorted(tottime)도 동일하게 키만 정렬해준다
for car in car_list:
    tottime[car]   # 정상 동작

딕셔너리를 "차량번호 오름차순으로 값 꺼내기"처럼 순서를 정해서 순회해야 할 때 자주 나오는 실수다. (예: 주차 요금 계산)

5. 무한 루프 주의

BFS에서 visited 처리를 꺼낼 때 하면 이미 중복으로 쌓인 노드를 막을 수 없다. 큐에 넣을 때 visited에 추가해야 한다.

# X: 꺼낼 때 visited 처리 → 같은 노드가 큐에 여러 번 쌓임
while queue:
    node = queue.popleft()
    visited.add(node)
    for neighbor in graph[node]:
        queue.append(neighbor)
 
# O: 넣을 때 visited 처리
while queue:
    node = queue.popleft()
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
            queue.append(neighbor)

6. 백트래킹에서 배열 복사

path는 재귀가 진행되면서 계속 바뀌는 리스트다. 그대로 result에 추가하면 나중에 path가 바뀔 때 result의 원소도 함께 바뀐다.

path = []
result = []
 
def backtrack(start):
    if len(path) == k:
        result.append(path)    # X: path 참조를 저장 → 이후 path.pop()이 result에도 반영됨
        result.append(path[:]) # O: 현재 상태를 복사해서 저장
        return
    for i in range(start, n):
        path.append(i)
        backtrack(i + 1)
        path.pop()

7. 2D 배열 초기화 시 행/열 순서 헷갈림

[[0] * m for _ in range(n)]에서 nm 중 뭐가 행이고 뭐가 열인지 자주 헷갈린다.

n = len(board)      # 행(row) 개수
m = len(board[0])   # 열(column) 개수
 
# O: board와 같은 n행 m열 모양
diff = [[0] * m for _ in range(n)]
 
# X: 반대로 쓰면 m행 n열이 되어, n != m일 때 모양이 뒤바뀐다
diff = [[0] * n for _ in range(m)]

[X for _ in range(Y)]에서 바깥 range(Y)가 "몇 개 만들지"(개수), 안쪽 X가 "각각 뭘로 채울지"(내용물)다. 2차원 배열을 만들 때는 "행을 range(행개수)번 반복하면서, 그때마다 [0]*열개수짜리 한 줄씩 만든다"고 순서대로 소리 내어 읽어보면 헷갈림이 줄어든다. n, m 대신 rows, cols처럼 의미가 드러나는 변수명을 쓰는 것도 도움이 된다.