pydongyang.gosky.kr / 파이썬 알고리즘
EXAM PREP · LECTURE 01–06

파이썬 알고리즘
시험 범위 총정리

그리디부터 이진 탐색까지. 단원마다 핵심 요점, 시험 포인트, 실행해 본 코드와 입출력, 확인 퀴즈로 정리했습니다.

6단원
29코드 예시
14확인 퀴즈
CHAPTER 01

강의 개요 · 시간 복잡도

왜 알고리즘을 배우는지, 그리고 효율을 재는 잣대인 빅오 표기법.

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ⁿ)지수천문학적모든 부분집합 탐색
CHECK
N이 100에서 10,000으로 100배 늘면 O(N²) 알고리즘의 연산량은 몇 배?
100² = 10,000배. O(N log N)이면 약 200배 정도로 훨씬 적게 늘어남.
CHAPTER 02

그리디 (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)
CHECK
곱하기 혹은 더하기에서 result가 1일 때 곱하지 않고 더하는 이유는?
1 × 5 = 5 이지만 1 + 5 = 6. 0이나 1과는 더하는 쪽이 항상 크거나 같다.
모험가 길드에서 정렬을 하지 않으면?
공포도 높은 사람이 먼저 와서 큰 그룹이 필요해지고, 남는 사람이 생겨 최대 그룹 수를 보장하지 못한다. 오름차순 정렬이 그리디의 정당성 근거.
CHAPTER 03

구현 (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
수업에서 다룬 질문: 숫자가 0만 있으면? 교재 코드는 if value != 0:으로 검사해서, 입력이 AB00이면 합이 0이라 숫자가 아예 출력되지 않는다(AB). 숫자 존재 여부를 따로 플래그로 기록하면 AB0이 올바르게 출력된다.
CHECK
시각 문제를 모든 경우를 다 세는 완전 탐색으로 풀어도 되는 이유는?
하루의 모든 시각은 24 × 60 × 60 = 86,400가지뿐이라 전부 확인해도 금방 끝난다. 경우의 수가 100만 개 이하면 완전 탐색을 고려할 수 있다.
나이트 위치 "c2"에서 열 c를 숫자로 바꾸는 식과 결과는?
ord('c') - ord('a') + 1 = 99 − 97 + 1 = 3.
CHAPTER 04

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, 깊이 우선 탐색)

  1. 탐색 시작 노드를 스택에 삽입하고 방문 처리
  2. 스택 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 노드를 스택에 넣고 방문 처리. 없으면 최상단 노드를 꺼냄
  3. 더 이상 수행할 수 없을 때까지 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, 너비 우선 탐색)

  1. 탐색 시작 노드를 큐에 삽입하고 방문 처리
  2. 큐에서 노드를 꺼내 그 노드의 인접 노드 중 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리
  3. 큐가 빌 때까지 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
DFSBFS
동작 원리스택 (깊게 먼저)큐 (가까운 것 먼저)
구현 방법재귀 함수 이용큐 자료구조(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끼리는 하나의 아이스크림. 만들 수 있는 아이스크림 개수.

  1. 특정 지점의 상하좌우 중 값이 0이고 방문하지 않은 지점이 있으면 방문
  2. 방문한 지점에서 다시 상하좌우를 보며 연결된 모든 지점 방문 (DFS)
  3. 모든 노드에 대해 반복하며, 방문하지 않은 지점에서 시작한 횟수를 셈
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
CHECK
미로 탈출을 DFS가 아니라 BFS로 푸는 이유는?
BFS는 시작점에서 가까운 칸부터 차례로 방문하므로, 처음 도달한 순간의 거리가 곧 최단 거리다. DFS는 먼저 깊게 들어가서 최단이 아닌 경로로 먼저 도착할 수 있다.
미로 탈출에서 시작점 (0,0)의 값은 탐색 후 몇이 되나?
시작점 자체는 방문 처리를 따로 안 하므로, 이웃에서 되돌아와 1 → 3으로 덮어써진다. 결과(도착점 값)에는 영향이 없지만 코드의 특징으로 알아둘 것.
재귀 함수에 종료 조건이 없으면?
무한히 자기 자신을 호출하다가 파이썬의 최대 재귀 깊이를 넘어 RecursionError가 발생한다.
CHAPTER 05

정렬 (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)
비교 횟수 (N=10)
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. 왼쪽부터 피벗보다 큰 데이터, 오른쪽부터 피벗보다 작은 데이터를 찾음
  2. 엇갈리지 않았으면 두 데이터를 교체
  3. 엇갈렸다면 작은 데이터와 피벗을 교체 → 피벗 기준 왼쪽은 작은 값, 오른쪽은 큰 값으로 분할 완료
  4. 왼쪽·오른쪽 부분 리스트에 같은 과정을 재귀 반복 (원소가 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=' ')        # 등장한 횟수만큼 인덱스 출력
count 리스트
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
성능 비교 실험 (수업 코드) 1~100 사이 랜덤 정수 10,000개 정렬: 선택 정렬 약 1.66초 vs 파이썬 기본 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])
CHECK
이미 정렬된 배열 [0,1,2,…,9]를 첫 원소 피벗 퀵 정렬로 정렬하면?
매번 피벗이 최솟값이라 분할이 한쪽으로만 일어나 O(N²). 반대로 삽입 정렬은 이 경우 O(N)으로 가장 빠르다.
계수 정렬을 쓸 수 없는 경우는?
데이터가 실수이거나, 값의 범위(최댓값−최솟값)가 너무 커서 카운트 리스트를 만들 수 없을 때.
선택 정렬로 원소 10개를 정렬할 때 비교 횟수는? (수업 코드의 operationCnt)
9+8+…+1 = 45회. 입력이 이미 정렬되어 있어도 항상 같다.
CHAPTER 06

이진 탐색 (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)
단계startendmid (H)잘린 떡 합판단
1019910+6+1+8 = 25충분 → result=9, 오른쪽
21019145+1+0+3 = 9충분 → result=14, 오른쪽
31519172+0+0+0 = 2부족 → 왼쪽
41516154+0+0+2 = 6충분 → result=15, 오른쪽
51616163+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 − left4 − 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)
CHECK
이진 탐색을 쓰기 위한 전제 조건은?
데이터가 정렬되어 있어야 한다. 정렬되어 있지 않으면 먼저 정렬(O(N log N))해야 한다.
떡볶이 문제에서 total ≥ m일 때 바로 끝내지 않고 start = mid + 1로 더 탐색하는 이유는?
구하는 것은 조건을 만족하는 H의 최댓값이기 때문. 일단 기록해 두고 더 높은 H도 가능한지 확인한다.
원소 1,024개 정렬 리스트에서 이진 탐색의 최대 비교 횟수는 대략?
log₂1024 = 10, 약 10~11회. 순차 탐색은 최대 1,024회.
SUMMARY

한 장 요약

시험 직전에 이것만 보기.

단원한 줄 핵심예제 → 키 아이디어복잡도
그리디지금 당장 최선 + 정당성 검토거스름돈 → 큰 동전부터
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