- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
1. 복잡도 분석
Big-O / Omega / Theta 표기법
알고리즘 분석의 핵심은 입력 크기 n에 따라 시간·공간 자원이 어떻게 증가하는지를 수학적으로 표현하는 것입니다.
| 표기법 | 의미 | 설명 |
|---|---|---|
| O(f(n)) | 상한(Upper Bound) | 최악의 경우 f(n)보다 빠르게 성장하지 않음 |
| Ω(f(n)) | 하한(Lower Bound) | 최선의 경우 f(n)보다 느리게 성장하지 않음 |
| Θ(f(n)) | 정확한 한계(Tight Bound) | 정확히 f(n)의 속도로 성장 |
주요 복잡도 비교 (느린 순서):
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
점화식과 마스터 정리
분할 정복 알고리즘의 복잡도는 점화식으로 표현되며, 마스터 정리로 풀 수 있습니다.
T(n) = aT(n/b) + f(n)
a= 하위 문제 개수,b= 분할 비율,f(n)= 분할/합병 비용
마스터 정리 3가지 케이스:
- f(n) = O(n^(log_b(a) - ε)) → T(n) = Θ(n^log_b(a))
- f(n) = Θ(n^log_b(a)) → T(n) = Θ(n^log_b(a) · log n)
- f(n) = Ω(n^(log_b(a) + ε)) → T(n) = Θ(f(n))
예시: 병합 정렬 T(n) = 2T(n/2) + O(n) → 케이스 2 → Θ(n log n)
Amortized Analysis (분할상환 분석)
개별 연산이 비싸더라도 연산 시퀀스 전체의 평균 비용을 분석합니다.
동적 배열(Dynamic Array) 예시:
- 용량 초과 시 2배 확장 → 해당 push는 O(n)
- n번의 push 전체 비용: O(1) + O(1) + ... + O(n) ≈ O(2n) = O(n)
- 분할상환 비용: O(1) per operation
2. 핵심 자료구조
자료구조 복잡도 요약
| 자료구조 | 접근 | 탐색 | 삽입 | 삭제 | 공간 |
|---|---|---|---|---|---|
| Array | O(1) | O(n) | O(n) | O(n) | O(n) |
| Linked List | O(n) | O(n) | O(1) | O(1) | O(n) |
| Stack | O(n) | O(n) | O(1) | O(1) | O(n) |
| Queue | O(n) | O(n) | O(1) | O(1) | O(n) |
| Hash Table | - | O(1)* | O(1)* | O(1)* | O(n) |
| BST (균형) | O(log n) | O(log n) | O(log n) | O(log n) | O(n) |
| Heap | O(1) (max) | O(n) | O(log n) | O(log n) | O(n) |
*평균 케이스
Red-Black Tree
BST의 균형을 자동으로 유지하는 자기 균형 이진 탐색 트리입니다.
5가지 속성:
- 모든 노드는 빨간색 또는 검은색
- 루트 노드는 검은색
- 모든 리프(NIL)는 검은색
- 빨간 노드의 자식은 모두 검은색
- 루트에서 임의의 리프까지 경로의 검은 노드 수는 동일
# Red-Black Tree 회전 - 좌회전(Left Rotation) 개념
#
# x y
# / \ → / \
# a y x c
# / \ / \
# b c a b
#
def left_rotate(tree, x):
y = x.right # y는 x의 오른쪽 자식
x.right = y.left # y의 왼쪽 서브트리를 x의 오른쪽으로
if y.left != tree.NIL:
y.left.parent = x
y.parent = x.parent # x의 부모를 y의 부모로
if x.parent == tree.NIL:
tree.root = y
elif x == x.parent.left:
x.parent.left = y
else:
x.parent.right = y
y.left = x # x를 y의 왼쪽 자식으로
x.parent = y
Heap과 우선순위 큐
힙은 완전 이진 트리 형태로 배열에 저장됩니다.
import heapq
# Dijkstra를 위한 최소 힙 활용
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
# (거리, 노드) 형태로 힙에 저장
heap = [(0, start)]
visited = set()
while heap:
dist, node = heapq.heappop(heap)
if node in visited:
continue
visited.add(node)
for neighbor, weight in graph[node]:
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return distances
# 사용 예시
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('C', 2), ('D', 5)],
'C': [('D', 1)],
'D': []
}
print(dijkstra(graph, 'A'))
# {'A': 0, 'B': 1, 'C': 3, 'D': 4}
3. 그래프 알고리즘
BFS vs DFS 비교
| 특성 | BFS | DFS |
|---|---|---|
| 자료구조 | 큐(Queue) | 스택(Stack) / 재귀 |
| 최단 경로 | O (가중치 없는 그래프) | X |
| 공간 복잡도 | O(V + E) | O(V) |
| 사용 사례 | 최단 경로, 레벨 탐색 | 사이클 감지, 위상 정렬, 연결 요소 |
최단 경로 알고리즘 비교
| 알고리즘 | 음수 간선 | 음수 사이클 감지 | 시간 복잡도 | 적용 범위 |
|---|---|---|---|---|
| Dijkstra | X | X | O((V+E) log V) | 단일 출발점 |
| Bellman-Ford | O | O | O(VE) | 단일 출발점 |
| Floyd-Warshall | O | O | O(V³) | 모든 쌍 |
Floyd-Warshall 구현
def floyd_warshall(n, edges):
INF = float('inf')
# dist[i][j] = i에서 j까지 최단 거리
dist = [[INF] * n for _ in range(n)]
for i in range(n):
dist[i][i] = 0
for u, v, w in edges:
dist[u][v] = w
for k in range(n): # 경유 노드
for i in range(n): # 출발 노드
for j in range(n): # 도착 노드
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
MST: Kruskal vs Prim
Kruskal (간선 기반, Union-Find 활용):
- 모든 간선을 가중치 오름차순 정렬 후 사이클 없이 선택
- 시간 복잡도: O(E log E)
- 희소 그래프(Sparse Graph)에 적합
Prim (노드 기반, 최소 힙 활용):
- 시작 노드에서 인접한 최소 가중치 간선을 반복 선택
- 시간 복잡도: O(E log V)
- 밀집 그래프(Dense Graph)에 적합
위상 정렬 (Topological Sort)
DAG(Directed Acyclic Graph)에서 의존 관계 순서를 결정합니다.
from collections import deque
def topological_sort(n, edges):
indegree = [0] * n
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
queue = deque([i for i in range(n) if indegree[i] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
queue.append(neighbor)
return result if len(result) == n else [] # 사이클 존재 시 빈 배열
4. 동적 프로그래밍
DP의 두 가지 접근법
최적 부분구조(Optimal Substructure): 문제의 최적해가 부분 문제의 최적해로 구성될 수 있음. 중복 부분문제(Overlapping Subproblems): 같은 부분 문제가 반복적으로 나타남.
| 방식 | 방향 | 장점 | 단점 |
|---|---|---|---|
| Memoization (Top-Down) | 재귀 + 캐시 | 필요한 부분만 계산 | 재귀 스택 오버헤드 |
| Tabulation (Bottom-Up) | 반복문 | 스택 오버플로 없음, 캐시 효율 | 불필요한 계산 포함 가능 |
LCS (최장 공통 부분 수열)
def lcs(s1, s2):
m, n = len(s1), len(s2)
# dp[i][j] = s1[:i]와 s2[:j]의 LCS 길이
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
# 2D 테이블 시각화 예시 (s1="ABCD", s2="ACBD"):
# "" A C B D
# "" 0 0 0 0 0
# A 0 1 1 1 1
# B 0 1 1 2 2
# C 0 1 2 2 2
# D 0 1 2 2 3 ← LCS 길이 = 3 ("ABD" 또는 "ACD")
return dp[m][n]
0/1 Knapsack
def knapsack(weights, values, capacity):
n = len(weights)
# dp[i][w] = i번째 아이템까지 고려, 용량 w일 때 최대 가치
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
# 아이템 i를 포함하지 않는 경우
dp[i][w] = dp[i-1][w]
# 아이템 i를 포함하는 경우
if weights[i-1] <= w:
dp[i][w] = max(dp[i][w],
dp[i-1][w - weights[i-1]] + values[i-1])
return dp[n][capacity]
5. 고급 알고리즘
Segment Tree (구간 트리)
배열의 구간 쿼리(합, 최솟값, 최댓값)를 O(log n)에 처리합니다.
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self.build(arr, 0, 0, self.n - 1)
def build(self, arr, node, start, end):
if start == end:
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
self.build(arr, 2*node+1, start, mid)
self.build(arr, 2*node+2, mid+1, end)
self.tree[node] = self.tree[2*node+1] + self.tree[2*node+2]
def query(self, node, start, end, l, r):
if r < start or end < l:
return 0 # 범위 밖
if l <= start and end <= r:
return self.tree[node] # 완전 포함
mid = (start + end) // 2
left = self.query(2*node+1, start, mid, l, r)
right = self.query(2*node+2, mid+1, end, l, r)
return left + right
def update(self, node, start, end, idx, val):
if start == end:
self.tree[node] = val
else:
mid = (start + end) // 2
if idx <= mid:
self.update(2*node+1, start, mid, idx, val)
else:
self.update(2*node+2, mid+1, end, idx, val)
self.tree[node] = self.tree[2*node+1] + self.tree[2*node+2]
Fenwick Tree (Binary Indexed Tree)
Segment Tree보다 구현이 간단하고 메모리 효율이 좋습니다.
class FenwickTree:
def __init__(self, n):
self.n = n
self.tree = [0] * (n + 1)
def update(self, i, delta):
while i <= self.n:
self.tree[i] += delta
i += i & (-i) # LSB(최하위 비트) 더하기
def query(self, i):
total = 0
while i > 0:
total += self.tree[i]
i -= i & (-i) # LSB 빼기
return total
def range_query(self, l, r):
return self.query(r) - self.query(l - 1)
| 자료구조 | 구현 복잡도 | 구간 쿼리 | 점 업데이트 | 구간 업데이트 | 메모리 |
|---|---|---|---|---|---|
| Segment Tree | 높음 | O(log n) | O(log n) | O(log n) | O(4n) |
| Fenwick Tree | 낮음 | O(log n) | O(log n) | 제한적 | O(n) |
Union-Find (경로 압축 + 랭크)
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
# 경로 압축(Path Compression)
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # 이미 같은 집합
# 랭크 기반 합집합(Union by Rank)
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
# 경로 압축 + 랭크 → 사실상 O(α(n)) ≈ O(1) (역 아커만 함수)
KMP 문자열 매칭
def kmp_search(text, pattern):
def build_lps(pattern):
lps = [0] * len(pattern)
length, i = 0, 1
while i < len(pattern):
if pattern[i] == pattern[length]:
length += 1
lps[i] = length
i += 1
elif length:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
return lps
lps = build_lps(pattern)
i = j = 0
results = []
while i < len(text):
if text[i] == pattern[j]:
i += 1; j += 1
if j == len(pattern):
results.append(i - j)
j = lps[j - 1]
elif i < len(text) and text[i] != pattern[j]:
j = lps[j - 1] if j else (i := i + 1) and 0
return results
6. AI/ML 연계 알고리즘
k-d Tree (k-NN 검색)
k-d tree는 k차원 공간에서 최근접 이웃(k-NN)을 효율적으로 검색합니다.
class KDNode:
def __init__(self, point, left=None, right=None):
self.point = point
self.left = left
self.right = right
def build_kd_tree(points, depth=0):
if not points:
return None
k = len(points[0])
axis = depth % k
sorted_points = sorted(points, key=lambda p: p[axis])
mid = len(sorted_points) // 2
return KDNode(
point=sorted_points[mid],
left=build_kd_tree(sorted_points[:mid], depth + 1),
right=build_kd_tree(sorted_points[mid+1:], depth + 1)
)
def nearest_neighbor(root, query, depth=0, best=None):
if root is None:
return best
k = len(query)
axis = depth % k
dist = sum((q - p) ** 2 for q, p in zip(query, root.point)) ** 0.5
if best is None or dist < best[0]:
best = (dist, root.point)
# 쿼리 포인트와 분할 초평면 비교
diff = query[axis] - root.point[axis]
close, away = (root.left, root.right) if diff <= 0 else (root.right, root.left)
best = nearest_neighbor(close, query, depth + 1, best)
# 반대편 서브트리도 검색이 필요한지 확인
if abs(diff) < best[0]:
best = nearest_neighbor(away, query, depth + 1, best)
return best
k-d tree 평균 복잡도: O(log n), 최악: O(n) (고차원에서 성능 저하 → "차원의 저주")
LSH (Locality-Sensitive Hashing)
고차원 데이터(임베딩 벡터)에서 유사한 데이터를 빠르게 찾는 근사 최근접 이웃(ANN) 기법입니다.
- 핵심 아이디어: 유사한 벡터는 같은 해시 버킷에 높은 확률로 들어감
- 코사인 유사도용 LSH: 랜덤 초평면으로 해시 생성
- 응용: 대규모 임베딩 검색(Faiss, ScaNN), 중복 문서 탐지
- 시간 복잡도: 쿼리 O(1) ~ O(log n), 인덱스 구축 O(nk)
Beam Search (NLP 디코딩)
Greedy 탐색의 개선: 각 단계에서 상위 k개(빔 폭) 후보를 유지합니다.
빔 폭 k=2, 각 토큰마다 상위 2개 유지:
단계 1: [A(0.6), B(0.4)]
단계 2: [AA(0.36), AB(0.24), BA(0.28), BB(0.16)] → 상위 2: [AA, BA]
단계 3: [AAX, AAY, BAX, BAY] → 상위 2 유지...
트레이드오프: 빔 폭 증가 → 품질 향상 but 메모리/시간 O(k·n) 증가
A* 알고리즘 (로보틱스/경로 계획)
Dijkstra + 휴리스틱 함수 h(n)을 결합한 최적 경로 탐색 알고리즘입니다.
f(n) = g(n) + h(n)
g(n) = 시작점에서 현재 노드까지 실제 비용
h(n) = 현재 노드에서 목표까지 추정 비용 (허용 가능 휴리스틱)
- 허용 가능(Admissible) 조건: h(n) ≤ 실제 비용 → 최적해 보장
- 로보틱스 응용: 그리드 맵 이동 계획, ROS Navigation Stack
- 시간 복잡도: O(E log V) (Dijkstra와 동일, 단 실제로 탐색 공간 대폭 감소)
7. 코딩 인터뷰 핵심 패턴
Two Pointers
# 정렬된 배열에서 합이 target인 두 수 찾기
def two_sum_sorted(arr, target):
left, right = 0, len(arr) - 1
while left < right:
current = arr[left] + arr[right]
if current == target:
return (left, right)
elif current < target:
left += 1
else:
right -= 1
return None
# 시간 O(n), 공간 O(1)
Sliding Window
# 길이 k인 서브배열의 최대 합
def max_subarray_sum(arr, k):
window_sum = sum(arr[:k])
max_sum = window_sum
for i in range(k, len(arr)):
window_sum += arr[i] - arr[i - k]
max_sum = max(max_sum, window_sum)
return max_sum
# 시간 O(n), 공간 O(1)
Backtracking
# N-Queens 문제
def solve_n_queens(n):
results = []
board = [-1] * n
def is_valid(row, col):
for r in range(row):
if board[r] == col:
return False
if abs(board[r] - col) == abs(r - row):
return False
return True
def backtrack(row):
if row == n:
results.append(board[:])
return
for col in range(n):
if is_valid(row, col):
board[row] = col
backtrack(row + 1)
board[row] = -1 # 백트래킹
backtrack(0)
return results
패턴 선택 가이드
| 문제 유형 | 추천 패턴 |
|---|---|
| 정렬된 배열, 쌍/삼중 조합 | Two Pointers |
| 연속 부분 배열/문자열 | Sliding Window |
| 조합/순열/경우의 수 | Backtracking |
| 최단 경로/레벨 탐색 | BFS |
| 사이클/위상/연결성 | DFS |
| 구간 쿼리/업데이트 | Segment Tree / Fenwick Tree |
| 집합 합치기/연결 판단 | Union-Find |
| 최적화 문제, 중복 부분문제 | Dynamic Programming |
8. 퀴즈: 실력 점검
Q1. 해시 테이블의 평균 O(1) 조회가 최악의 경우 O(n)이 되는 상황은?
정답: 모든 키가 동일한 버킷으로 해시 충돌(Hash Collision)될 때
설명: 해시 테이블은 해시 함수로 키를 버킷에 매핑합니다. 이상적인 경우 충돌이 없어 O(1)이지만, 나쁜 해시 함수나 악의적인 입력으로 모든 n개의 키가 동일한 버킷에 들어가면 체이닝(Chaining) 방식에서 해당 버킷의 연결 리스트를 선형 탐색해야 하므로 O(n)이 됩니다. 이를 방지하기 위해 로드 팩터(Load Factor) 초과 시 재해싱(Rehashing), 랜덤 해시 함수(Universal Hashing), 또는 Robin Hood Hashing 등을 사용합니다.
Q2. Dijkstra 알고리즘이 음수 가중치 간선에서 동작하지 않는 이유는?
정답: Dijkstra는 "이미 방문한 노드의 거리는 최단"이라는 탐욕적(Greedy) 가정에 기반하는데, 음수 간선이 있으면 이 가정이 깨집니다.
설명: Dijkstra는 최소 힙에서 꺼낸 노드를 "확정"으로 표시합니다. 음수 간선이 있으면, 이미 확정된 노드 A에서 B를 통한 경로 A → B → A가 A의 거리를 줄일 수 있습니다(음수 사이클이 없어도). 즉, 나중에 방문한 노드를 통해 이전에 확정한 노드의 거리가 개선될 수 있어 알고리즘의 전제가 성립하지 않습니다. 음수 간선에는 Bellman-Ford 알고리즘을 사용해야 합니다.
Q3. DP에서 메모이제이션과 타뷸레이션의 공간 복잡도 차이는?
정답: 메모이제이션은 호출된 부분 문제만 저장(희소할 때 유리), 타뷸레이션은 전체 테이블을 채우므로 항상 O(n) 이상. 단, 타뷸레이션은 슬라이딩 윈도우로 공간 최적화 가능.
설명: 메모이제이션은 Top-Down 방식으로 재귀 호출 시 캐시에 저장하며, 실제로 필요한 부분 문제만 계산합니다. 전체 부분 문제 수가 n이어도 실제 호출이 k개면 O(k) 공간만 사용합니다. 반면 타뷸레이션은 Bottom-Up으로 테이블 전체를 채우므로 항상 O(n) 공간을 사용합니다. 그러나 피보나치 수열처럼 이전 두 값만 필요한 경우 dp[i] = dp[i-1] + dp[i-2]를 O(1) 공간으로 최적화할 수 있습니다.
Q4. k-d tree가 k-NN 검색에서 평균적으로 효율적인 이유와 한계는?
정답: 공간을 반씩 분할하여 탐색 범위를 줄이므로 평균 O(log n). 단, 고차원(d가 클수록)에서 "차원의 저주"로 성능이 O(n)에 수렴.
설명: k-d tree는 각 레벨에서 하나의 축을 기준으로 데이터를 반으로 나눕니다. 최근접 이웃 탐색 시 쿼리 포인트와 분할 초평면 거리를 비교해 불필요한 서브트리를 가지치기(pruning)합니다. 저차원(d ≤ 20)에서는 평균 O(log n)으로 효율적이지만, 차원이 높아질수록 분할 초평면과의 거리가 작아져 가지치기 효과가 줄어들고 결국 모든 포인트를 방문하게 됩니다. 고차원에서는 LSH나 HNSW(Hierarchical Navigable Small World) 같은 근사 최근접 이웃(ANN) 알고리즘이 더 효율적입니다.
Q5. Segment Tree와 Fenwick Tree(BIT)의 구현 복잡도와 사용 케이스 차이는?
정답: Segment Tree는 구현 복잡하나 범용적(구간 업데이트, 다양한 집계), Fenwick Tree는 구현 간단하고 빠르나 부분합 등 한정적 연산에 특화.
설명: Fenwick Tree(BIT)는 비트 연산(LSB)으로 구현이 매우 간결하고 캐시 효율이 좋습니다. 주로 prefix sum(구간 합) 쿼리와 점 업데이트에 사용됩니다. Segment Tree는 구현이 복잡하지만 구간 최솟값/최댓값, Lazy Propagation을 통한 구간 업데이트, 복잡한 집계 함수(GCD, XOR 등)까지 지원합니다. 경쟁 프로그래밍에서 "구간 합만 필요하면 BIT, 나머지는 Segment Tree"가 일반적 가이드라인입니다.
정리
알고리즘과 자료구조는 AI 엔지니어링의 핵심 기반입니다.
- 복잡도 분석은 시스템 설계에서 병목을 예측하고 스케일링 결정을 내리는 데 필수적입니다.
- 그래프 알고리즘은 지식 그래프, 추천 시스템, 경로 최적화에 직접 활용됩니다.
- DP는 시퀀스 모델링, 강화학습의 벨만 방정식과 깊이 연결됩니다.
- k-d tree, LSH, Beam Search는 현대 ML 시스템의 효율적 추론을 가능하게 하는 핵심 알고리즘입니다.
꾸준한 문제 풀이와 구현 연습으로 이론과 실전 모두를 잡으세요.