파이썬 알고리즘
시험 범위 총정리
그리디부터 이진 탐색까지. 단원마다 핵심 요점, 시험 포인트, 실행해 본 코드와 입출력, 확인 퀴즈로 정리했습니다.
강의 개요 · 시간 복잡도
왜 알고리즘을 배우는지, 그리고 효율을 재는 잣대인 빅오 표기법.
AI 시대에 알고리즘을 배우는 이유
- 바이브 코딩(Vibe Coding): AI(Cursor, Copilot 등)에게 말로 시켜서 코드를 만드는 방식
- 원리를 모르면 블랙박스 문제 — 왜 동작하는지/왜 느린지 설명 불가
- AI는 환각(Hallucination)을 일으킬 수 있음 → 검증 능력 필요
- 검토 없이 쌓인 코드 = 기술 부채, 스파게티 코드
- 개발자의 역할: 코더(Coder) → 설계자(Architect) / 오케스트레이터
빅오 표기법 (Big-O)
- 입력 크기
N이 커질 때 연산 횟수가 늘어나는 추세를 표현 - 가장 큰 차수만 남기고 상수·계수는 무시 (
3N² + 5N → O(N²)) - 보통 최악의 경우(Worst Case) 기준으로 판단
- NP-Hard: 다항 시간에 정답을 못 구하는 문제 → 그리디, DP, 분기 한정(Branch & Bound), 근사(Approximation)로 접근
복잡도 순서 (빠름 → 느림)
| 표기 | 이름 | N = 100일 때 대략 | 대표 예 |
|---|---|---|---|
O(1) | 상수 | 1 | 리스트 인덱스 접근 |
O(log N) | 로그 | 약 7 | 이진 탐색 |
O(N) | 선형 | 100 | 순차 탐색, 반복문 1개 |
O(N log N) | 선형 로그 | 약 700 | 퀵 정렬(평균), 파이썬 sort() |
O(N²) | 이차 | 10,000 | 선택·삽입·버블 정렬, 이중 반복문 |
O(2ⁿ) | 지수 | 천문학적 | 모든 부분집합 탐색 |
N이 100에서 10,000으로 100배 늘면 O(N²) 알고리즘의 연산량은 몇 배?
그리디 (Greedy)
현재 상황에서 가장 좋아 보이는 것만 고른다. 단, 그게 정답임을 증명할 수 있어야 한다.
핵심 요점
- 현재 상황에서 지금 당장 가장 좋은 것만 고르는 방법
- 매 순간의 최선이 전체의 최적해를 보장하는지 정당성 검토가 반드시 필요
- 대부분 정렬과 짝을 이룸 (작은 것부터 / 큰 것부터)
- 그리디로 풀리지 않으면 DP·완전 탐색 등 다른 방법을 고려
- 거스름돈이 그리디로 풀리는 이유: 큰 단위가 항상 작은 단위의 배수이기 때문
- 반례: 동전이 500, 400, 100원이고 800원을 거슬러야 하면 그리디는 500+100+100+100(4개), 최적은 400+400(2개)
- 루트 노드에서 합이 최대인 경로 찾기 — 매번 큰 쪽만 따라가면 최적이 아닐 수 있음 (그리디의 한계)
예제 1거스름돈 O(K) · K = 동전 종류 수
500, 100, 50, 10원 동전이 무한히 있을 때 N원을 거슬러 줄 최소 동전 개수. 큰 단위부터 최대한 거슬러 준다.
n = 1260
count = 0
# 큰 단위의 화폐부터 차례대로 확인
array = [500, 100, 50, 10]
for coin in array:
count += n // coin # 해당 화폐로 거슬러 줄 수 있는 동전 개수
n %= coin # 남은 금액
print(count)
500×2 → 260 100×2 → 60 50×1 → 10 10×1 → 0
6
예제 21이 될 때까지 O(log N)
N에서 ① 1을 빼거나 ② K로 나누기(나누어떨어질 때만)를 반복해 1로 만드는 최소 횟수. → 최대한 많이 나누기가 최적 (빼기보다 나누기가 N을 훨씬 빨리 줄임).
n, k = map(int, input().split())
result = 0
# N이 K 이상이라면 K로 계속 나누기
while n >= k:
# N이 K로 나누어 떨어지지 않는다면 N에서 1씩 빼기
while n % k != 0:
n -= 1
result += 1
# K로 나누기
n //= k
result += 1
# 마지막으로 남은 수에 대하여 1씩 빼기
while n > 1:
n -= 1
result += 1
print(result)
25 5
2 (25 → 5 → 1)
n, k = map(int, input().split())
result = 0
while True:
# N이 K로 나누어 떨어지는 수가 될 때까지 한 번에 빼기
target = (n // k) * k
result += (n - target)
n = target
# N이 K보다 작을 때 (더 이상 나눌 수 없을 때) 탈출
if n < k:
break
# K로 나누기
result += 1
n //= k
# 마지막으로 남은 수에 대하여 1씩 빼기
result += (n - 1)
print(result)
17 4
3 (17 → 16 → 4 → 1)
if n < k가 왜 필요할까?
K의 배수까지 빼고 난 n이 K보다 작다면(예: n=3, k=5 → target=0) 더 이상 나눌 수 없으므로 반복을 끝내야 한다. 이 검사를 while n >= k: 조건으로 옮기면 반복을 한 번 덜 도는 버전(getMinimumCalcCnt_2)이 된다.예제 3모험가 길드 O(N log N)
공포도가 X인 모험가는 X명 이상인 그룹에만 참여 가능. 만들 수 있는 그룹 수의 최댓값. → 오름차순 정렬 후 공포도 낮은 사람부터 그룹을 만든다 (최소 인원으로 그룹을 많이).
n = int(input())
data = list(map(int, input().split()))
data.sort()
result = 0 # 총 그룹의 수
count = 0 # 현재 그룹에 포함된 모험가의 수
for i in data: # 공포도를 낮은 것부터 하나씩 확인하며
count += 1 # 현재 그룹에 해당 모험가를 포함시키기
if count >= i: # 인원 수 ≥ 현재 공포도 → 그룹 결성
result += 1
count = 0
print(result)
5 2 3 1 2 2
2 ([1] / [2,2] — 남은 2,3은 그룹 불가)
예제 4곱하기 혹은 더하기 O(N)
숫자 문자열을 왼쪽부터 보며 × 또는 +를 넣어 만들 수 있는 가장 큰 수. → 두 수 중 하나라도 0 또는 1이면 더하기, 그 외엔 곱하기.
data = input()
# 첫 번째 문자를 숫자로 변경하여 대입
result = int(data[0])
for i in range(1, len(data)):
# 두 수 중에서 하나라도 '0' 혹은 '1'인 경우, 곱하기보다는 더하기 수행
num = int(data[i])
if num <= 1 or result <= 1:
result += num
else:
result *= num
print(result)
02984
576 ((((0+2)×9)×8)×4)
곱하기 혹은 더하기에서 result가 1일 때 곱하지 않고 더하는 이유는?
모험가 길드에서 정렬을 하지 않으면?
구현 (Implementation)
머릿속 풀이를 정확히 코드로 옮기는 힘. 시뮬레이션과 완전 탐색.
핵심 요점
- 머릿속 풀이를 소스코드로 바꾸는 과정 — 알고리즘은 쉬운데 코드가 길고 까다로운 문제
- 대표 유형: 시뮬레이션(문제 그대로 따라가기), 완전 탐색(Brute Force, 모든 경우 확인), 문자열 처리
- 2차원 공간은 행렬(Matrix)로 생각:
(행, 열), 행은 아래로 증가, 열은 오른쪽으로 증가 - 방향 벡터
dx, dy리스트로 이동을 표현하면 코드가 깔끔해짐
- 완전 탐색은 경우의 수가 대략 100만 개 이하일 때 고려 (시각 문제: 24×60×60 = 86,400)
- 범위를 벗어나는 이동은
continue로 무시 ord()로 문자 → 숫자 변환 (ord('c') - ord('a') + 1 = 3)
# 동, 북, 서, 남
dx = [0, -1, 0, 1]
dy = [1, 0, -1, 0]
# 현재 위치
x, y = 2, 2
for i in range(4):
# 다음 위치
nx = x + dx[i]
ny = y + dy[i]
print(nx, ny)
x = 행(세로), y = 열(가로)
2 3 1 2 2 1 3 2
예제 1상하좌우 시뮬레이션 · O(N)
N×N 공간의 (1,1)에서 출발, L·R·U·D 계획대로 이동. 공간 밖으로 나가는 이동은 무시. 최종 좌표 출력.
n = int(input())
x, y = 1, 1
plans = input().split()
# L, R, U, D에 따른 이동 방향
dx = [0, 0, -1, 1]
dy = [-1, 1, 0, 0]
move_types = ['L', 'R', 'U', 'D']
# 이동 계획을 하나씩 확인
for plan in plans:
# 이동 후 좌표 구하기
for i in range(len(move_types)):
if plan == move_types[i]:
nx = x + dx[i]
ny = y + dy[i]
# 공간을 벗어나는 경우 무시
if nx < 1 or ny < 1 or nx > n or ny > n:
continue
# 이동 수행
x, y = nx, ny
print(x, y)
5 R R R U D D
3 4
예제 2시각 완전 탐색
00시 00분 00초 ~ N시 59분 59초 중 3이 하나라도 포함된 시각의 수. 하루는 86,400초뿐이라 전부 세어도 충분히 빠르다.
h = int(input())
count = 0
for i in range(h + 1):
for j in range(60):
for k in range(60):
# 매 시각 안에 '3'이 포함되어 있다면 카운트 증가
if '3' in str(i) + str(j) + str(k):
count += 1
print(count)
5
11475
예제 3왕실의 나이트 시뮬레이션
8×8 체스판에서 나이트(L자 이동: 수평 2+수직 1 또는 수직 2+수평 1)가 이동할 수 있는 경우의 수. 열은 a~h, 행은 1~8.
input_data = input()
row = int(input_data[1])
column = int(ord(input_data[0])) - int(ord('a')) + 1
# 나이트가 이동할 수 있는 8가지 방향 정의
steps = [(-2, -1), (-1, -2), (1, -2), (2, -1),
(2, 1), (1, 2), (-1, 2), (-2, 1)]
result = 0
for step in steps:
next_row = row + step[0]
next_column = column + step[1]
# 해당 위치로 이동이 가능하다면 카운트 증가
if 1 <= next_row <= 8 and 1 <= next_column <= 8:
result += 1
print(result)
a1
2 (c2 입력 시 6)
예제 4문자열 재정렬
알파벳 대문자와 숫자(0~9)로 된 문자열 → 알파벳은 오름차순 정렬, 그 뒤에 숫자의 합을 붙여 출력.
data = input()
result = []
value = 0
has_number = False
for x in data:
if x.isalpha(): # 알파벳인 경우 결과 리스트에 삽입
result.append(x)
else: # 숫자는 따로 더하기
value += int(x)
has_number = True
result.sort() # 알파벳을 오름차순으로 정렬
# 숫자가 하나라도 존재하는 경우 가장 뒤에 삽입
if has_number:
result.append(str(value))
print(''.join(result)) # 리스트를 문자열로 변환하여 출력
K1KA5CB7
ABCKK13
if value != 0:으로 검사해서, 입력이 AB00이면 합이 0이라 숫자가 아예 출력되지 않는다(AB). 숫자 존재 여부를 따로 플래그로 기록하면 AB0이 올바르게 출력된다.시각 문제를 모든 경우를 다 세는 완전 탐색으로 풀어도 되는 이유는?
나이트 위치 "c2"에서 열 c를 숫자로 바꾸는 식과 결과는?
ord('c') - ord('a') + 1 = 99 − 97 + 1 = 3.DFS / BFS
스택·큐·재귀를 바탕으로 그래프를 깊게, 또는 넓게 탐색한다.
기초 자료구조: 스택 · 큐 · 재귀
스택 (Stack) — 선입후출 FILO
- 박스 쌓기: 나중에 넣은 것을 먼저 꺼냄
- 파이썬 리스트:
append()/pop() - DFS, 재귀 호출과 관련
큐 (Queue) — 선입선출 FIFO
- 대기 줄: 먼저 온 사람이 먼저 나감
collections.deque:append()/popleft()- 리스트의
pop(0)은 O(N)이라 느림 → deque는 O(1) - BFS에 사용
stack = []
stack.append(5)
stack.append(2)
stack.append(3)
stack.append(7)
stack.pop()
stack.append(1)
stack.append(4)
stack.pop()
print(stack[::-1]) # 최상단 원소부터 출력
print(stack) # 최하단 원소부터 출력
7, 4
[1, 3, 2, 5] [5, 2, 3, 1]
from collections import deque
queue = deque()
queue.append(5)
queue.append(2)
queue.append(3)
queue.append(7)
queue.popleft()
queue.append(1)
queue.append(4)
queue.popleft()
print(queue) # 먼저 들어온 순서대로 출력
queue.reverse() # 역순으로 바꾸기
print(queue) # 나중에 들어온 원소부터 출력
5, 2
deque([3, 7, 1, 4]) deque([4, 1, 7, 3])
재귀 함수 (Recursive Function)
- 자기 자신을 다시 호출하는 함수. 종료 조건이 없으면 무한 호출 →
RecursionError(최대 재귀 깊이 초과) - 내부적으로 스택처럼 동작: 가장 마지막에 호출된 함수가 가장 먼저 끝남 (A→B→C 호출, C→B→A 종료)
- 수학의 점화식을 그대로 코드로 옮길 수 있어 간결함
# 반복적으로 구현한 n!
def factorial_iterative(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
# 재귀적으로 구현한 n!
def factorial_recursive(n):
if n <= 1: # 종료 조건: n이 1 이하인 경우 1을 반환 (0! = 1)
return 1
return n * factorial_recursive(n - 1) # n! = n × (n-1)!
print(factorial_iterative(5))
print(factorial_recursive(5))
n! = n × (n−1)!
120 120
# A를 B로 나눈 나머지를 R이라 하면, GCD(A, B) = GCD(B, R)
def gcd(a, b):
if a % b == 0:
return b
else:
return gcd(b, a % b)
print(gcd(192, 162))
(192,162) → (162,30) → (30,12) → (12,6)
6
그래프 표현 방식
| 인접 행렬 (Adjacency Matrix) | 인접 리스트 (Adjacency List) | |
|---|---|---|
| 방식 | 2차원 배열에 연결 관계 저장 (연결 X는 0 또는 무한) | 각 노드마다 연결된 노드 목록 저장 |
| 메모리 | O(N²) — 낭비가 큼 | O(N + E) — 연결된 것만 저장 |
| 두 노드 연결 확인 | O(1) 빠름 | 목록을 훑어야 해서 느림 |
| 적합 | 간선이 많은 밀집 그래프 | 간선이 적은 희소 그래프 (SNS 등) |
graph = [
[], # 0번 노드는 사용하지 않음
[2, 3, 8], # 1
[1, 7], # 2
[1, 4, 5], # 3
[3, 5], # 4
[3, 4], # 5
[7], # 6
[2, 6, 8], # 7
[1, 7] # 8
]
visited = [False] * 9
DFS (Depth-First Search, 깊이 우선 탐색)
- 탐색 시작 노드를 스택에 삽입하고 방문 처리
- 스택 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 노드를 스택에 넣고 방문 처리. 없으면 최상단 노드를 꺼냄
- 더 이상 수행할 수 없을 때까지 2번 반복 (인접 노드는 번호가 낮은 순서부터)
def dfs(graph, v, visited):
# 현재 노드를 방문 처리
visited[v] = True
print(v, end=' ')
# 현재 노드와 연결된 다른 노드를 재귀적으로 방문
for i in graph[v]:
if not visited[i]:
dfs(graph, i, visited)
dfs(graph, 1, visited)
스택 (재귀 호출 스택)
1 2 7 6 8 3 4 5
BFS (Breadth-First Search, 너비 우선 탐색)
- 탐색 시작 노드를 큐에 삽입하고 방문 처리
- 큐에서 노드를 꺼내 그 노드의 인접 노드 중 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리
- 큐가 빌 때까지 2번 반복
from collections import deque
def bfs(graph, start, visited):
queue = deque([start])
# 현재 노드를 방문 처리
visited[start] = True
# 큐가 빌 때까지 반복
while queue:
# 큐에서 하나의 원소를 뽑아 출력
v = queue.popleft()
print(v, end=' ')
# 아직 방문하지 않은 인접 원소들을 큐에 삽입
for i in graph[v]:
if not visited[i]:
queue.append(i)
visited[i] = True
visited = [False] * 9
bfs(graph, 1, visited)
큐 (deque)
1 2 3 8 7 4 5 6
| DFS | BFS | |
|---|---|---|
| 동작 원리 | 스택 (깊게 먼저) | 큐 (가까운 것 먼저) |
| 구현 방법 | 재귀 함수 이용 | 큐 자료구조(deque) 이용 |
| 시간 복잡도 | O(N + E) | O(N + E) |
| 잘 쓰이는 곳 | 연결 요소 개수 세기, 모든 경로 탐색 | 최단 거리 (간선 비용이 모두 같을 때) |
- 위 그래프의 DFS/BFS 방문 순서를 손으로 따라 쓸 수 있어야 함 (1 2 7 6 8 3 4 5 / 1 2 3 8 7 4 5 6)
- BFS는 큐에 넣을 때 방문 처리 (꺼낼 때 하면 중복 삽입 발생)
- 2차원 격자도 그래프: 각 칸 = 노드, 상하좌우 = 간선
예제 1음료수 얼려 먹기 DFS · 연결 요소 개수
N×M 얼음 틀에서 0은 구멍, 1은 칸막이. 상하좌우로 붙은 0끼리는 하나의 아이스크림. 만들 수 있는 아이스크림 개수.
- 특정 지점의 상하좌우 중 값이 0이고 방문하지 않은 지점이 있으면 방문
- 방문한 지점에서 다시 상하좌우를 보며 연결된 모든 지점 방문 (DFS)
- 모든 노드에 대해 반복하며, 방문하지 않은 지점에서 시작한 횟수를 셈
n, m = map(int, input().split())
graph = []
for i in range(n):
graph.append(list(map(int, input())))
# DFS로 특정한 노드를 방문한 뒤에 연결된 모든 노드들도 방문
def dfs(x, y):
# 주어진 범위를 벗어나는 경우에는 즉시 종료
if x <= -1 or x >= n or y <= -1 or y >= m:
return False
# 현재 노드를 아직 방문하지 않았다면
if graph[x][y] == 0:
graph[x][y] = 1 # 해당 노드 방문 처리
# 상, 하, 좌, 우의 위치들도 모두 재귀적으로 호출
dfs(x - 1, y)
dfs(x, y - 1)
dfs(x + 1, y)
dfs(x, y + 1)
return True
return False
result = 0
for i in range(n):
for j in range(m):
if dfs(i, j) == True: # 현재 위치에서 DFS 수행
result += 1
print(result)
4 5 00110 00011 11111 00000
3
예제 2미로 탈출 BFS · 최단 거리
N×M 미로, (1,1)에서 (N,M)까지. 0은 괴물(벽), 1은 길. 시작·끝 칸을 포함한 최소 이동 칸 수. → BFS로 방문하며 이전 칸 값 + 1을 기록.
from collections import deque
n, m = map(int, input().split())
graph = []
for i in range(n):
graph.append(list(map(int, input())))
# 이동할 네 가지 방향 정의 (상, 하, 좌, 우)
dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]
def bfs(x, y):
queue = deque()
queue.append((x, y))
# 큐가 빌 때까지 반복하기
while queue:
x, y = queue.popleft()
# 현재 위치에서 4가지 방향으로의 위치 확인
for i in range(4):
nx = x + dx[i]
ny = y + dy[i]
# 미로 찾기 공간을 벗어난 경우 무시
if nx < 0 or nx >= n or ny < 0 or ny >= m:
continue
# 벽인 경우 무시
if graph[nx][ny] == 0:
continue
# 해당 노드를 처음 방문하는 경우에만 최단 거리 기록
if graph[nx][ny] == 1:
graph[nx][ny] = graph[x][y] + 1
queue.append((nx, ny))
# 가장 오른쪽 아래까지의 최단 거리 반환
return graph[n - 1][m - 1]
print(bfs(0, 0))
5 6 101010 111111 000001 111111 111111
10
미로 탈출을 DFS가 아니라 BFS로 푸는 이유는?
미로 탈출에서 시작점 (0,0)의 값은 탐색 후 몇이 되나?
재귀 함수에 종료 조건이 없으면?
RecursionError가 발생한다.정렬 (Sorting)
네 가지 정렬의 동작 원리와 시간 복잡도 차이. 시험 단골 비교표 포함.
핵심 요점
- 데이터를 특정 기준에 따라 순서대로 나열하는 것
- 정렬해 두면 이진 탐색(O(log N))이 가능해짐 → 정렬은 이진 탐색의 전처리
- 상황(데이터 크기, 범위, 거의 정렬되어 있는지)에 따라 적절한 알고리즘이 다름
- 실전에서는 대부분 파이썬 기본
sort()/sorted()사용 (팀 정렬, Timsort = 병합 + 삽입, 최악 O(N log N) 보장)
정렬 알고리즘 비교 시험 단골
| 알고리즘 | 평균 | 최악 | 최선 | 특징 |
|---|---|---|---|---|
| 선택 정렬 | O(N²) | O(N²) | O(N²) | 아이디어가 가장 간단. 비교 횟수 항상 N(N−1)/2 |
| 삽입 정렬 | O(N²) | O(N²) | O(N) | 거의 정렬된 데이터에서 매우 빠름 |
| 퀵 정렬 | O(N log N) | O(N²) | O(N log N) | 대부분 가장 빠름. 이미 정렬된 데이터에서 최악 (첫 원소 피벗일 때) |
| 계수 정렬 | O(N + K) | O(N + K) | O(N + K) | K = 최댓값. 데이터 범위가 작은 정수일 때만. 공간 복잡도 큼 |
| 파이썬 sort() | O(N log N) | O(N log N) | O(N) | 팀 정렬(Timsort). 최악도 보장 |
선택 정렬 (Selection Sort)
처리되지 않은 데이터 중 가장 작은 데이터를 선택해 맨 앞의 데이터와 바꾸는 것을 반복.
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(len(array)):
min_index = i # 가장 작은 원소의 인덱스
for j in range(i + 1, len(array)):
if array[min_index] > array[j]:
min_index = j
array[i], array[min_index] = array[min_index], array[i] # 스와프
print(array)
9+8+…+1 = 45 = N(N−1)/2
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
삽입 정렬 (Insertion Sort)
데이터를 하나씩 확인하며, 앞쪽의 이미 정렬된 부분에서 적절한 위치에 삽입. 자기보다 작은 데이터를 만나면 그 자리에서 멈춘다.
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(1, len(array)):
for j in range(i, 0, -1): # 인덱스 i부터 1까지 감소하며 반복
if array[j] < array[j - 1]: # 한 칸씩 왼쪽으로 이동
array[j], array[j - 1] = array[j - 1], array[j]
else: # 자기보다 작은 데이터를 만나면 멈춤
break
print(array)
첫 원소는 이미 정렬된 것으로 보고 i = 1부터 시작
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
퀵 정렬 (Quick Sort)
피벗(Pivot)을 정하고, 피벗보다 큰 데이터와 작은 데이터의 위치를 바꾸는 방식으로 리스트를 분할(Divide)한 뒤 양쪽을 재귀적으로 정렬 (호어 분할: 첫 원소를 피벗으로).
- 왼쪽부터 피벗보다 큰 데이터, 오른쪽부터 피벗보다 작은 데이터를 찾음
- 엇갈리지 않았으면 두 데이터를 교체
- 엇갈렸다면 작은 데이터와 피벗을 교체 → 피벗 기준 왼쪽은 작은 값, 오른쪽은 큰 값으로 분할 완료
- 왼쪽·오른쪽 부분 리스트에 같은 과정을 재귀 반복 (원소가 1개면 종료)
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array, start, end):
if start >= end: # 원소가 1개인 경우 종료
return
pivot = start # 피벗은 첫 번째 원소
left = start + 1
right = end
while left <= right:
# 피벗보다 큰 데이터를 찾을 때까지 반복
while left <= end and array[left] <= array[pivot]:
left += 1
# 피벗보다 작은 데이터를 찾을 때까지 반복
while right > start and array[right] >= array[pivot]:
right -= 1
if left > right: # 엇갈렸다면 작은 데이터와 피벗을 교체
array[right], array[pivot] = array[pivot], array[right]
else: # 엇갈리지 않았다면 작은 데이터와 큰 데이터를 교체
array[left], array[right] = array[right], array[left]
# 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬 수행
quick_sort(array, start, right - 1)
quick_sort(array, right + 1, end)
quick_sort(array, 0, len(array) - 1)
print(array)
[1, 4, 2, 0, 3, 5, 6, 9, 7, 8] ← 5보다 작음 | 5 | 5보다 큼 →
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array):
# 리스트가 하나 이하의 원소만을 담고 있다면 종료
if len(array) <= 1:
return array
pivot = array[0] # 피벗은 첫 번째 원소
tail = array[1:] # 피벗을 제외한 리스트
left_side = [x for x in tail if x <= pivot] # 분할된 왼쪽 부분
right_side = [x for x in tail if x > pivot] # 분할된 오른쪽 부분
return quick_sort(left_side) + [pivot] + quick_sort(right_side)
print(quick_sort(array))
코드는 짧지만 매번 새 리스트를 만들고 tail을 두 번 순회 → 시간·공간 더 사용
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
계수 정렬 (Counting Sort)
데이터 값 자체를 인덱스로 쓰는 카운트 리스트에 등장 횟수를 기록하고, 앞에서부터 횟수만큼 출력. 비교 기반이 아님.
- 조건: 데이터가 정수이고, 최댓값과 최솟값 차이가 대략 1,000,000 이하일 때
- 데이터가 0과 999,999 단 2개뿐이어도 리스트 크기가 100만 → 공간 낭비 심함
- 동일한 값이 여러 번 등장할 때 유리 (예: 성적)
# 모든 원소의 값이 0보다 크거나 같다고 가정
array = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2]
# 모든 범위를 포함하는 리스트 선언 (모든 값은 0으로 초기화)
count = [0] * (max(array) + 1)
for i in range(len(array)):
count[array[i]] += 1 # 각 데이터에 해당하는 인덱스의 값 증가
for i in range(len(count)): # 리스트에 기록된 정렬 정보 확인
for j in range(count[i]):
print(i, end=' ') # 등장한 횟수만큼 인덱스 출력
idx: 0 1 2 3 4 5 6 7 8 9 cnt: 2 2 2 1 1 2 1 1 1 2
0 0 1 1 2 2 3 4 5 5 6 7 8 9 9
sort() 약 0.0008초. 직접 짠 O(N²)과 라이브러리 O(N log N)의 차이를 체감하는 실험.예제두 배열의 원소 교체 정렬 + 그리디
배열 A, B(각 N개)에서 최대 K번 원소를 바꿔치기해 A의 원소 합을 최대로. → A는 오름차순, B는 내림차순 정렬 후 앞에서부터 A[i] < B[i]일 때만 교체.
n, k = map(int, input().split())
a = list(map(int, input().split()))
b = list(map(int, input().split()))
a.sort() # 배열 A는 오름차순 정렬
b.sort(reverse=True) # 배열 B는 내림차순 정렬
# 첫 번째 인덱스부터 확인하며, 두 배열의 원소를 최대 K번 비교
for i in range(k):
if a[i] < b[i]: # A의 원소가 B의 원소보다 작은 경우 교체
a[i], b[i] = b[i], a[i]
else: # 크거나 같으면 더 바꿀 이유가 없으므로 탈출
break
print(sum(a))
5 3 1 2 5 4 3 5 5 6 6 5
26 (A = [6, 6, 5, 4, 5])
이미 정렬된 배열 [0,1,2,…,9]를 첫 원소 피벗 퀵 정렬로 정렬하면?
계수 정렬을 쓸 수 없는 경우는?
선택 정렬로 원소 10개를 정렬할 때 비교 횟수는? (수업 코드의 operationCnt)
이진 탐색 (Binary Search)
정렬된 데이터에서 범위를 절반씩 줄여 O(log N)으로 찾는다.
순차 탐색 (Sequential Search)
- 앞에서부터 하나씩 차례대로 확인
- 정렬 여부 상관없음
O(N)
이진 탐색 (Binary Search)
- 정렬된 리스트에서만 사용 가능
- 시작점·끝점·중간점으로 탐색 범위를 매번 절반으로 줄임
O(log N)— 32개 → 16 → 8 → 4 …
- 중간점
mid = (start + end) // 2— 소수점 이하 버림 - 찾는 값이 중간점보다 작으면
end = mid - 1, 크면start = mid + 1 start > end가 되면 값이 없는 것- 탐색 범위가 매우 크면(예: 1~10억) 이진 탐색을 떠올리기 — 파라메트릭 서치: 최적화 문제를 "예/아니오" 결정 문제로 바꿔 이진 탐색
이진 탐색 기본 — 재귀 / 반복문
def binary_search(array, target, start, end):
if start > end:
return None
mid = (start + end) // 2
# 찾은 경우 중간점 인덱스 반환
if array[mid] == target:
return mid
# 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
elif array[mid] > target:
return binary_search(array, target, start, mid - 1)
# 중간점의 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
else:
return binary_search(array, target, mid + 1, end)
n, target = list(map(int, input().split()))
array = list(map(int, input().split()))
result = binary_search(array, target, 0, n - 1)
if result == None:
print("원소가 존재하지 않습니다.")
else:
print(result + 1)
10 7 1 3 5 7 9 11 13 15 17 19
4 (7은 4번째 원소)
def binary_search(array, target, start, end):
while start <= end:
mid = (start + end) // 2
if array[mid] == target:
return mid
elif array[mid] > target:
end = mid - 1
else:
start = mid + 1
return None
[0,9] mid=4 → 9 > 7, 왼쪽 [0,3] mid=1 → 3 < 7, 오른쪽 [2,3] mid=2 → 5 < 7, 오른쪽 [3,3] mid=3 → 7 찾음!
3 (인덱스)
예제 1떡볶이 떡 만들기 파라메트릭 서치
절단기 높이 H로 떡들을 자르면 H보다 긴 부분이 잘려 손님이 가져감. 손님이 요청한 길이 M 이상을 얻기 위한 H의 최댓값.
- "높이 H로 자르면 M 이상 얻을 수 있는가?" — 예/아니오로 바꿔 H를 이진 탐색 (0 ~ 가장 긴 떡)
- 떡 양이 충분하면 결과 기록 후 더 높여 보기(오른쪽), 부족하면 더 낮추기(왼쪽)
# 떡의 개수(N)와 요청한 떡의 길이(M)을 입력
n, m = list(map(int, input().split(' ')))
# 각 떡의 개별 높이 정보를 입력
array = list(map(int, input().split()))
# 이진 탐색을 위한 시작점과 끝점 설정
start = 0
end = max(array)
result = 0
while start <= end:
total = 0
mid = (start + end) // 2
for x in array:
# 잘랐을 때의 떡의 양 계산
if x > mid:
total += x - mid
# 떡의 양이 부족한 경우 더 많이 자르기 (왼쪽 부분 탐색)
if total < m:
end = mid - 1
# 떡의 양이 충분한 경우 덜 자르기 (오른쪽 부분 탐색)
else:
result = mid # 최대한 덜 잘랐을 때가 정답이므로 여기에서 기록
start = mid + 1
print(result)
4 6 19 15 10 17
15 (4+0+0+2 = 6cm)
| 단계 | start | end | mid (H) | 잘린 떡 합 | 판단 |
|---|---|---|---|---|---|
| 1 | 0 | 19 | 9 | 10+6+1+8 = 25 | 충분 → result=9, 오른쪽 |
| 2 | 10 | 19 | 14 | 5+1+0+3 = 9 | 충분 → result=14, 오른쪽 |
| 3 | 15 | 19 | 17 | 2+0+0+0 = 2 | 부족 → 왼쪽 |
| 4 | 15 | 16 | 15 | 4+0+0+2 = 6 | 충분 → result=15, 오른쪽 |
| 5 | 16 | 16 | 16 | 3+0+0+1 = 4 | 부족 → 왼쪽 → 종료 |
예제 2정렬된 배열에서 특정 수의 개수 O(log N) 필수
정렬된 N개 수열에서 x의 개수. 없으면 -1. 선형 탐색(O(N))으로는 시간 초과 → bisect로 x가 시작하는 위치와 끝나는 위치를 각각 이진 탐색.
| 함수 | 의미 | a = [1, 2, 4, 4, 8], x = 4 |
|---|---|---|
bisect_left(a, x) | 정렬 순서를 유지하며 x를 넣을 가장 왼쪽 인덱스 | 2 |
bisect_right(a, x) | 정렬 순서를 유지하며 x를 넣을 가장 오른쪽 인덱스 | 4 |
| 값이 x인 원소 개수 = right − left | 4 − 2 = 2 | |
from bisect import bisect_left, bisect_right
n, x = map(int, input().split())
array = list(map(int, input().split()))
# 값이 [x, x] 범위에 있는 데이터의 개수 계산
count = bisect_right(array, x) - bisect_left(array, x)
if count == 0: # 값이 x인 원소가 존재하지 않는다면
print(-1)
else:
print(count)
7 2 1 1 2 2 2 2 3
4 (x = 4라면 -1)
이진 탐색을 쓰기 위한 전제 조건은?
떡볶이 문제에서 total ≥ m일 때 바로 끝내지 않고 start = mid + 1로 더 탐색하는 이유는?
원소 1,024개 정렬 리스트에서 이진 탐색의 최대 비교 횟수는 대략?
한 장 요약
시험 직전에 이것만 보기.
| 단원 | 한 줄 핵심 | 예제 → 키 아이디어 | 복잡도 |
|---|---|---|---|
| 그리디 | 지금 당장 최선 + 정당성 검토 | 거스름돈 → 큰 동전부터 1이 될 때까지 → 최대한 나누기 모험가 길드 → 오름차순 정렬 곱하기/더하기 → 0·1은 더하기 | 보통 O(N) ~ O(N log N) |
| 구현 | 문제를 그대로 코드로 | 상하좌우 → dx/dy, 범위 밖 무시 시각 → 86,400개 완전 탐색 나이트 → 8방향 steps, ord() 문자열 재정렬 → isalpha(), join | 문제마다 다름 |
| DFS | 스택 / 재귀, 깊게 먼저 | 음료수 얼려 먹기 → 연결 요소 수 | O(N + E) |
| BFS | 큐(deque), 가까운 것 먼저 | 미로 탈출 → 이전 칸 + 1 = 최단 거리 | O(N + E) |
| 정렬 | 선택·삽입 O(N²), 퀵 O(N log N), 계수 O(N+K) | 두 배열 교체 → A 오름, B 내림, A<B일 때만 | sort() = O(N log N) |
| 이진 탐색 | 정렬된 데이터, 범위 절반씩 | 떡볶이 → 파라메트릭 서치 x 개수 → bisect_right − bisect_left | O(log N) |
n, k = map(int, input().split())— 공백으로 구분된 정수 입력list(map(int, input()))— "00110" 같은 붙은 숫자 문자열을 리스트로a, b = b, a— 스와프arr[::-1]역순,sorted(arr, reverse=True)내림차순from collections import deque/from bisect import bisect_left, bisect_right//몫,%나머지,ord('a')= 97