PS를 위한 파이썬 정리
코딩테스트에서 자주 사용했던 파이썬 문법, 라이브러리, 알고리즘 템플릿 등 정리
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] | O | O | O | O | 동적 배열 | |
| Tuple | (1, 2, 3) | X | O | O | X | 해시 가능 | |
| Dict | {k: v} | O | O (3.7+) | X (key) | O | key-value | |
| Set | {1, 2, 3} | O | X | X | O | 중복 제거 |
시간복잡도 정리
| 연산 | List | Dict | Set | 설명 |
|---|---|---|---|---|
| 조회 | Dict/Set은 해시 사용 | |||
| 삽입 | List 맨 앞 삽입은 | |||
| 삭제 | 위치를 알아야 함 | |||
| 정렬 | - | - | 비교 기반 | |
in 연산 | 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 20for-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-else는 for 앞에 온다.
# 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)) # 7sorted() 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 pairs는 for 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
양쪽 끝에서 시간에 추가/제거가 가능한 큐(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()로 에 힙으로 변환할 수 있다.
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 -1while len(scoville) > 1:로 섞을 재료가 2개 미만이면 루프를 멈춰서, 더 이상 섞을 수 없는 경우를 자연스럽게 -1로 떨어뜨린다. 루프 시작 전에 scoville[0] >= K를 한 번 확인하는 것도 중요한데, 없으면 이미 조건을 만족한 상태에서도 불필요하게 한 번 더 섞어버린다.
itertools (순열, 조합, 중복순열, 중복조합)
순서를 고려하면 순열(permutations), 순서 상관없이 고르면 조합(combinations), 같은 원소를 여러 번 뽑을 수 있으면 중복순열(product) 또는 중복조합(combinations_with_replacement)을 사용한다.
| 유형 | 순서 | 중복 | 함수 |
|---|---|---|---|
| 순열 | O | X | permutations |
| 조합 | X | X | combinations |
| 중복순열 | O | O | product |
| 중복조합 | X | O | combinations_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]정렬된 배열에서 특정 범위에 값이 몇 개 있는지 에 구할 수 있다. for문으로 세면 이다.
# arr에서 4 이상 7 이하인 값의 개수
left = bisect.bisect_left(arr, 4)
right = bisect.bisect_right(arr, 7)
count = right - leftmath
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에 저장하면 로 확인 가능하다.
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 + 18. 정렬
정렬 자체가 답인 문제도 있지만, 대부분은 정렬을 전처리로 쓰고 그 위에 다른 로직을 얹는다. 배열을 정렬해두면 이진 탐색, 투 포인터, 그리디 같은 알고리즘을 바로 적용할 수 있게 된다. 커스텀 기준으로 정렬해야 할 때는 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)
9. 수학 / 소수
N 이하의 소수를 전부 구해야 하거나, 어떤 수가 소수인지 판별해야 할 때 이 유형이다. N이 크면 으로 하나씩 확인하면 시간초과가 나므로, 범위 내 모든 소수는 에라토스테네스의 체()로, 단일 수 판별은 까지만 확인하는 방식으로 줄인다. GCD/LCM은 math.gcd로 바로 쓸 수 있고, 약수 개수나 소인수분해가 목적이면 루프로 충분하다.
소수 찾기 문제에서는 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)
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단조 스택
현재 원소보다 크거나 작은 다음 원소를 찾아야 할 때 쓴다. 스택에 인덱스를 쌓다가 현재 값이 스택 top보다 크면 pop하며 정답을 기록한다. 에 해결된다.
스택에는 값이 아니라 인덱스를 저장한다. 스택에 남아있다는 건 "아직 자기보다 크거나 작은 상대를 못 만나서, 답을 아직 확정 못 한 원소"라는 뜻이고, 그 원소가 몇 번째였는지를 알아야 최종적으로 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:에서 "끝까지 안 떨어졌으니 배열 끝까지의 시간"으로 따로 채운다.
큐 (순서 시뮬레이션)
순서대로 처리하고 결과를 다시 뒤에 넣는 시뮬레이션 문제에 쓴다.
프로세스 문제에서는 맨 앞을 꺼내봤을 때, 큐 안에 그보다 우선순위 높은 게 남아있으면 다시 뒤로 보내고(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 2475 | GOLD | |
| 소 조깅(SILVER) | COJ 2365 | GOLD |
11. 힙 (Priority Queue)
현재 가장 작거나 큰 것을 반복적으로 꺼내야 할 때 힙을 쓴다. 매번 정렬하면 이 쌓이지만, 힙은 삽입/삭제가 이라 훨씬 효율적이다. 파이썬은 최소 힙만 기본 제공하므로, 최대 힙이 필요하면 값에 -를 붙여서 넣고 꺼낼 때 다시 -를 붙인다.
더 맵게 문제에서는 가장 작은 것 두 개를 뽑아서 섞고 다시 넣는 걸 반복한다.
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)
12. 해시 / 딕셔너리
특정 값이 있는지 빠르게 확인하거나, 등장 횟수를 세야 할 때 딕셔너리를 쓴다. 리스트에서 in 연산은 이지만 딕셔너리는 이라 데이터가 많을수록 차이가 커진다. 빈도 계산은 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 TrueCounter끼리 ==로 통째로 비교하는 예시다. 할인 행사에서, 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 2458 | GOLD | |
| 공통 수 | COJ 2457 | GOLD | |
| 빈도수 | COJ 2456 | GOLD | |
| 직선의 개수 구하기 | COJ 2455 | GOLD | |
| 두 집합의 차집합 구하기 | COJ 2454 | GOLD | |
| 나는야 포켓몬 마스터 이다솜 | BOJ 1620 | Silver 4 |
13. 투 포인터
배열에서 두 원소의 관계나 특정 구간을 찾아야 할 때, 브루트포스 대신 으로 풀 수 있는 기법이다. 포인터 두 개를 이동시키며 탐색하므로, 각 포인터가 한 방향으로만 이동한다는 조건이 성립해야 한다.
구간 축소형 (한쪽에서 시작, 같은 방향)
조건을 만족하는 가장 짧거나 긴 연속 구간을 찾아야 할 때 쓴다. 한 포인터로 구간을 늘리고, 조건이 충족되면 다른 포인터로 구간을 줄이며 최적값을 갱신한다.
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])정확히 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 += 1wsize = 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 |
양 끝 수렴형 (양 끝에서 시작, 좁혀옴)
정렬된 배열에서 두 원소의 합이 특정 조건을 만족하는 쌍을 찾을 때 쓴다. 합이 크면 오른쪽 포인터를 줄이고, 작으면 왼쪽 포인터를 늘리는 방식으로 에 해결한다. 배열이 정렬되어 있어야 한다는 전제가 필요하다.
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)14. 구간합 (Prefix Sum)
같은 구간의 합을 여러 번 물어보는 문제에서 매번 합을 구하면 (는 쿼리 수)가 된다. 미리 누적합 배열을 만들어두면 각 쿼리를 에 처리할 수 있다. 단, 배열 원소가 변하지 않을 때만 쓸 수 있다. 값이 바뀌면서 구간합도 물어보면 세그먼트 트리를 써야 한다.
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 2405 | GOLD | |
| COW | COJ 2383 | GOLD | |
| 게으른 소 | COJ 2342 | GOLD | |
| 구간의 합 구하기 (1D) | 정올 3135 | 입문 | |
| 수열 | BOJ 2559 | Silver 3 | |
| 나머지 합 | BOJ 10986 | Gold 3 | |
| 구간 합 구하기 4 | BOJ 11659 | Silver 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 2199 | GOLD |
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 -1dist[nx][ny] == 0이 곧 "아직 방문 안 함"이고, 방문하는 순간 dist[x][y] + 1이 들어가서 거리도 같이 기록된다.
연습 문제 (6)
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)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))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 answerDFS (스택)
재귀 깊이 제한을 피해야 할 때 스택으로 직접 구현한다. 동작은 재귀 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)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))g와 q 모두 (비용, 노드) 순서로 통일해서 담는다. 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/False가 1/0으로 취급되는 걸 이용해 sum()으로 조건 만족 개수를 한 줄에 센다.
연습 문제 (16)
| 문제 | 출처 | 난이도 | 비고 |
|---|---|---|---|
| 그래프 최소 비용 | SWEA 5263 | D4 | |
| 짝수 만들기 | COJ 2392 | GOLD | |
| 소 번호 매기기 | COJ 2208 | GOLD | |
| 배달 경로 | COJ 2201 | EMERALD | |
| 두 GPS의 결투 | COJ 2354 | EMERALD | |
| 도로 차단 | COJ 2337 | EMERALD | |
| 물류 배송 | COJ 2413 | PLATINUM | |
| 배달 | 프로그래머스 | Lv.2 | |
| 최소비용 | COJ 2196 | GOLD | |
| 최단경로 | BOJ 1753 | Gold 4 | |
| 최소비용 구하기 | BOJ 1916 | Gold 5 | |
| 녹색 옷 입은 애가 젤다지? | BOJ 4485 | Gold 4 | |
| 특정한 최단 경로 | BOJ 1504 | Gold 4 | |
| 알고스팟 | BOJ 1261 | Gold 4 | |
| 택배 배송 | BOJ 5972 | Gold 5 | |
| 해킹 | BOJ 10282 | Gold 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 15623 | D4 | |
| 균형 잡힌 팀 | COJ 2328 | GOLD | |
| 합승 택시 요금 | 프로그래머스 | Lv.3 | |
| 우주 회의에 다녀오는 최단 항로 | COJ 2239 | MASTER | |
| 파티 | BOJ 1238 | Gold 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("음수 사이클 존재")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)
중복순열 (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)조합 — 포함/미포함 방식
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 | |
| 태양이의 캠프 조합 | SWEA | D6 | |
| 조합의 약수의 개수 | SWEA | D5 | |
| 암호 만들기 | BOJ 1759 | Gold 5 | |
| 차이를 최대로 | BOJ 10819 | Silver 2 |
18. 이진 탐색
기본 템플릿
정렬된 배열에서 특정 값이 있는지, 또는 몇 번째 위치에 있는지 찾아야 할 때 쓴다. 이라 원소가 수백만 개여도 빠르다. 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 1920 | Silver 4 | |
| 숫자 카드 2 | BOJ 10816 | Silver 4 |
답이 범위에 있는 경우 (파라메트릭 서치)
답 자체가 어떤 범위 안에 있고, 특정 값이 조건을 만족하는지 이하로 검증할 수 있으면 이진 탐색으로 에 풀 수 있다. 조건을 만족하는 가장 작은 값을 찾는 패턴이다.
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)
19. Union-Find (Disjoint Set Union)
두 원소가 같은 그룹에 속하는지 확인하거나, 그룹을 합치는 연산이 반복될 때 쓴다. 연결된 컴포넌트 개수를 세거나, 사이클 존재 여부를 판별하는 문제에 자주 등장한다. DFS로 연결 여부를 확인하면 쿼리마다 이지만, Union-Find를 쓰면 거의 에 처리할 수 있다.
기본 구조
find로 루트를 찾고, union으로 두 집합을 합친다. (예: 집합의 표현) find를 호출할 때 거쳐간 노드들을 루트에 직접 연결하는 것을 경로 압축이라 하고, 다음 find가 에 가까워진다.
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] = ryp = 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)는 x와 y 자체가 아니라 각자의 루트(rx, ry)를 구해서 합친다. x나 y가 이미 다른 원소와 합쳐진 상태라면 자기 자신이 루트가 아닐 수 있기 때문에, 합쳐야 하는 대상은 원소 하나가 아니라 그 원소가 속한 그룹 전체다. p[rx] = ry는 rx 그룹의 대표가 ry 그룹의 대표를 가리키게 만들어서 두 그룹을 하나로 합친다.
연습 문제 (8)
크루스칼 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/union을 solution 안에 중첩 함수로 넣어서 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 1197 | Gold 4 | |
| 울타리 치기(PLATINUM) | COJ 2438 | EMERALD | |
| 슈퍼불 | COJ 2387 | EMERALD | |
| 농장 단순화 | COJ 2195 | EMERALD |
오프라인 쿼리: 제거를 역순으로 뒤집기
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 13306 | Platinum 5 | |
| 선물 | COJ 2274 | DIAMOND | |
| 백설공주와 N명의 난쟁이 | COJ 2272 | DIAMOND |
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)
겹치는 예외를 먼저 제거하고 집계하기
두 그룹(예: "도난당한 사람"과 "여벌이 있는 사람") 사이에 겹치는 원소가 있고, 그 겹치는 원소는 "이미 스스로 해결됐다"고 처리해야 할 때 쓰는 패턴이다. (예: 체육복)
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 += 1answer = 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) # 최대 회의 개수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)
격자형 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])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)
22. 세그먼트 트리
배열 원소가 자주 바뀌면서 구간 합도 자주 구해야 할 때 쓴다. 구간합만 필요하고 값이 안 바뀌면 prefix sum으로 충분하지만, 값이 중간에 바뀌면 prefix sum은 재계산에 이 든다. 세그먼트 트리는 둘 다 에 처리한다.
| 연산 | Prefix Sum | 세그먼트 트리 |
|---|---|---|
| 구간합 구하기 | ||
| 값 변경 |
개념
배열을 완전 이진 트리로 표현한다. 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)
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 2463 | SILVER | |
| 블록 게임 | COJ 2462 | SILVER | |
| 화난 소(BRONZE) | COJ 2416 | GOLD | |
| 상한 우유 | COJ 2402 | GOLD | |
| 과속 딱지 | COJ 2401 | GOLD | |
| 울타리 칠하기 | COJ 2400 | SILVER | |
| 건초더미에 갇히다(BRONZE) | COJ 2393 | GOLD | |
| 소 길찾기 II | COJ 2373 | GOLD | |
| 소 길찾기 | COJ 2372 | SILVER | |
| 십자말풀이 | COJ 2360 | GOLD | |
| 마라톤(BRONZE) | COJ 2359 | GOLD | |
| 소 재배치 | COJ 2341 | GOLD | |
| 만나서 반가워 | COJ 2240 | SILVER | |
| 줄지어 선 소들 | COJ 2222 | GOLD | |
| 농장 탈출 | COJ 2191 | GOLD | |
| Moo Sick | COJ 2165 | SILVER | |
| 품종 근접성 | COJ 2288 | SILVER | |
| 소들의 경주 | COJ 2287 | SILVER | |
| 동아리 부원 모집하기 | COJ 2278 | SILVER | |
| 밧줄 접기 | COJ 2204 | GOLD | |
| 울트라 369 | COJ 2279 | GOLD | |
| 로봇 청소기 | BOJ 14503 | Gold 5 | |
| 뱀 | BOJ 3190 | Gold 4 | |
| 톱니바퀴 | BOJ 14891 | Gold 5 | |
| 드래곤 커브 | BOJ 15685 | Gold 3 | |
| 주사위 굴리기 | BOJ 14499 | Gold 5 | |
| 감시 | BOJ 15683 | Gold 4 | |
| 치킨 배달 | BOJ 15686 | Gold 5 | |
| 미세먼지 안녕! | BOJ 17144 | Gold 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 최적화
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)]에서 n과 m 중 뭐가 행이고 뭐가 열인지 자주 헷갈린다.
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처럼 의미가 드러나는 변수명을 쓰는 것도 도움이 된다.