Split View: 알고리즘 & 자료구조 완전 정복: 복잡도 분석부터 AI/ML 알고리즘까지
알고리즘 & 자료구조 완전 정복: 복잡도 분석부터 AI/ML 알고리즘까지
목차
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 시스템의 효율적 추론을 가능하게 하는 핵심 알고리즘입니다.
꾸준한 문제 풀이와 구현 연습으로 이론과 실전 모두를 잡으세요.
Algorithms & Data Structures: From Complexity Analysis to AI/ML Algorithms
Table of Contents
- Complexity Analysis
- Core Data Structures
- Graph Algorithms
- Dynamic Programming
- Advanced Algorithms
- AI/ML Algorithm Connections
- Coding Interview Patterns
- Quiz: Test Your Knowledge
1. Complexity Analysis
Big-O, Omega, and Theta Notation
The core of algorithm analysis is expressing mathematically how time and space resources grow with input size n.
| Notation | Meaning | Description |
|---|---|---|
| O(f(n)) | Upper Bound | Does not grow faster than f(n) in the worst case |
| Ω(f(n)) | Lower Bound | Does not grow slower than f(n) in the best case |
| Θ(f(n)) | Tight Bound | Grows at exactly the rate of f(n) |
Common complexity classes (from slowest to fastest growth):
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
Recurrence Relations and the Master Theorem
Divide-and-conquer algorithms are expressed as recurrences, solvable with the Master Theorem.
T(n) = aT(n/b) + f(n)
a= number of subproblems,b= division factor,f(n)= split/merge cost
Three cases of the Master Theorem:
- 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))
Example: Merge sort T(n) = 2T(n/2) + O(n) → Case 2 → Θ(n log n)
Amortized Analysis
Analyzes the average cost per operation over a sequence of operations, even when individual operations can be expensive.
Dynamic Array example:
- Doubling capacity on overflow → that push is O(n)
- Total cost of n pushes: O(1) + O(1) + ... + O(n) ≈ O(2n) = O(n)
- Amortized cost: O(1) per operation
2. Core Data Structures
Complexity Summary
| Data Structure | Access | Search | Insert | Delete | Space |
|---|---|---|---|---|---|
| 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 (balanced) | 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) |
*Average case
Red-Black Tree
A self-balancing BST that automatically maintains balance through rotations and recoloring.
5 properties:
- Every node is either red or black
- The root is black
- All leaves (NIL) are black
- Both children of every red node are black
- All paths from root to any leaf have the same number of black nodes
# Red-Black Tree Rotation - Left Rotation concept
#
# x y
# / \ → / \
# a y x c
# / \ / \
# b c a b
#
def left_rotate(tree, x):
y = x.right # y is x's right child
x.right = y.left # move y's left subtree to x's right
if y.left != tree.NIL:
y.left.parent = x
y.parent = x.parent # link x's parent to 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 # put x on y's left
x.parent = y
Heap and Priority Queue
A heap is a complete binary tree stored as an array.
import heapq
# Using min-heap for Dijkstra's algorithm
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
# Store (distance, node) tuples in the heap
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
# Example usage
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. Graph Algorithms
BFS vs DFS Comparison
| Property | BFS | DFS |
|---|---|---|
| Data structure | Queue | Stack / Recursion |
| Shortest path | Yes (unweighted) | No |
| Space complexity | O(V + E) | O(V) |
| Use cases | Shortest path, level traversal | Cycle detection, topological sort, components |
Shortest Path Algorithm Comparison
| Algorithm | Negative edges | Detect negative cycles | Time complexity | Scope |
|---|---|---|---|---|
| Dijkstra | No | No | O((V+E) log V) | Single source |
| Bellman-Ford | Yes | Yes | O(VE) | Single source |
| Floyd-Warshall | Yes | Yes | O(V³) | All pairs |
Floyd-Warshall Implementation
def floyd_warshall(n, edges):
INF = float('inf')
# dist[i][j] = shortest distance from i to 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): # intermediate node
for i in range(n): # source node
for j in range(n): # destination node
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 (edge-based, uses Union-Find):
- Sort all edges by weight ascending, then greedily add edges that don't form cycles
- Time complexity: O(E log E)
- Better for sparse graphs
Prim (vertex-based, uses min-heap):
- Grow MST from a start node by repeatedly adding the minimum-weight edge crossing the cut
- Time complexity: O(E log V)
- Better for dense graphs
Topological Sort
Orders nodes in a DAG (Directed Acyclic Graph) respecting dependencies.
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 [] # empty if cycle detected
4. Dynamic Programming
Two Approaches to DP
Optimal Substructure: The optimal solution to a problem can be constructed from optimal solutions to its subproblems. Overlapping Subproblems: The same subproblems are solved multiple times.
| Approach | Direction | Advantages | Disadvantages |
|---|---|---|---|
| Memoization (Top-Down) | Recursion + cache | Only computes needed subproblems | Recursive call overhead |
| Tabulation (Bottom-Up) | Iteration | No stack overflow risk, cache-friendly | May compute unnecessary subproblems |
LCS (Longest Common Subsequence)
def lcs(s1, s2):
m, n = len(s1), len(s2)
# dp[i][j] = LCS length of s1[:i] and s2[:j]
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 table visualization (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 length = 3 ("ABD" or "ACD")
return dp[m][n]
0/1 Knapsack
def knapsack(weights, values, capacity):
n = len(weights)
# dp[i][w] = max value using first i items, with capacity w
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
# Don't include item i
dp[i][w] = dp[i-1][w]
# Include item 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. Advanced Algorithms
Segment Tree
Handles range queries (sum, min, max) in O(log n) per operation.
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 # out of range
if l <= start and end <= r:
return self.tree[node] # fully inside range
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)
Simpler to implement than Segment Tree, with better memory efficiency.
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) # add the lowest set bit
def query(self, i):
total = 0
while i > 0:
total += self.tree[i]
i -= i & (-i) # remove the lowest set bit
return total
def range_query(self, l, r):
return self.query(r) - self.query(l - 1)
| Structure | Implementation | Range Query | Point Update | Range Update | Memory |
|---|---|---|---|---|---|
| Segment Tree | Complex | O(log n) | O(log n) | O(log n) | O(4n) |
| Fenwick Tree | Simple | O(log n) | O(log n) | Limited | O(n) |
Union-Find (Path Compression + Rank)
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 # already in the same set
# 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
# Path compression + rank -> effectively O(α(n)) ≈ O(1) (inverse Ackermann)
KMP String Matching
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 Algorithm Connections
k-d Tree for k-NN Search
The k-d tree partitions k-dimensional space to enable efficient nearest-neighbor queries.
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)
# Compare query to splitting hyperplane
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)
# Check if we need to explore the other side
if abs(diff) < best[0]:
best = nearest_neighbor(away, query, depth + 1, best)
return best
k-d tree average complexity: O(log n), worst case: O(n) — performance degrades in high dimensions ("curse of dimensionality")
LSH (Locality-Sensitive Hashing)
An approximate nearest neighbor (ANN) method for finding similar high-dimensional vectors (e.g., embeddings) quickly.
- Core idea: Similar vectors hash to the same bucket with high probability
- Cosine similarity LSH: Hash by random hyperplane projections
- Applications: Large-scale embedding search (Faiss, ScaNN), near-duplicate detection
- Time complexity: Query O(1) ~ O(log n), index build O(nk)
Beam Search in NLP Decoding
An improvement over greedy search: maintains top k candidates (beam width) at each step.
Beam width k=2, keeping top 2 tokens per step:
Step 1: [A(0.6), B(0.4)]
Step 2: [AA(0.36), AB(0.24), BA(0.28), BB(0.16)] -> top 2: [AA, BA]
Step 3: [AAX, AAY, BAX, BAY] -> keep top 2...
Tradeoff: Larger beam width improves output quality but memory/time scales as O(k·n).
A* Algorithm in Robotics / Path Planning
Combines Dijkstra with a heuristic function h(n) for optimal and efficient pathfinding.
f(n) = g(n) + h(n)
g(n) = actual cost from start to current node
h(n) = estimated cost from current node to goal (admissible heuristic)
- Admissibility condition: h(n) is less than or equal to the true cost → guarantees optimality
- Robotics applications: Grid-map motion planning, ROS Navigation Stack
- Time complexity: O(E log V) — same as Dijkstra, but dramatically reduces search space in practice
7. Coding Interview Patterns
Two Pointers
# Find two numbers in a sorted array that sum to 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
# Time O(n), Space O(1)
Sliding Window
# Maximum sum of subarray of length 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
# Time O(n), Space O(1)
Backtracking
# N-Queens problem
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 # undo choice
backtrack(0)
return results
Pattern Selection Guide
| Problem Type | Recommended Pattern |
|---|---|
| Sorted array, pair/triple combinations | Two Pointers |
| Contiguous subarray or substring | Sliding Window |
| Combinations, permutations, counting | Backtracking |
| Shortest path, level traversal | BFS |
| Cycles, topology, connectivity | DFS |
| Range queries and updates | Segment Tree / Fenwick Tree |
| Merging sets, connectivity checks | Union-Find |
| Optimization with overlapping subproblems | Dynamic Programming |
8. Quiz: Test Your Knowledge
Q1. Under what circumstances does a hash table's average O(1) lookup degrade to O(n)?
Answer: When all keys hash to the same bucket — a worst-case hash collision scenario.
Explanation: A hash table maps keys to buckets using a hash function. In the ideal case with no collisions, lookup is O(1). However, with a poor hash function or adversarial inputs, all n keys can land in the same bucket. With chaining, this bucket becomes a linked list that must be traversed linearly, giving O(n). Solutions include rehashing when the load factor exceeds a threshold, universal hashing (randomized hash functions), or open addressing strategies like Robin Hood hashing.
Q2. Why does Dijkstra's algorithm fail with negative-weight edges?
Answer: Dijkstra relies on the greedy assumption that once a node is settled, its distance is final. Negative edges violate this assumption.
Explanation: Dijkstra marks a node as "finalized" when it is popped from the min-heap, assuming no future path can improve it. With negative edges, a path through a later-visited node B could reduce the distance to an already-finalized node A (even without a negative cycle). The algorithm's invariant breaks down. Use Bellman-Ford for graphs with negative edges — it relaxes all edges V-1 times and can detect negative cycles.
Q3. What is the space complexity difference between memoization and tabulation in DP?
Answer: Memoization stores only the subproblems that are actually called (advantageous when many subproblems are skipped); tabulation always fills the entire table O(n) but can be space-optimized with a sliding window.
Explanation: Top-down memoization uses a cache (dict/array) and only computes subproblems encountered during recursion. If only k of n total subproblems are needed, it uses O(k) space. Bottom-up tabulation fills the entire dp table, always using O(n) space. However, for problems where dp[i] depends only on dp[i-1] (like Fibonacci), the table can be reduced to O(1) by keeping only the previous values.
Q4. Why is a k-d tree efficient for k-NN search on average, and what are its limitations?
Answer: It halves the search space at each level, giving average O(log n). However, the "curse of dimensionality" causes performance to approach O(n) as d grows large.
Explanation: A k-d tree splits data along one axis per level. During nearest-neighbor search, it prunes entire subtrees when the distance to the splitting hyperplane exceeds the current best distance. This pruning is highly effective in low dimensions (d is less than or equal to 20), averaging O(log n). But in high dimensions, the distance to the hyperplane is almost always less than the current best, so pruning rarely triggers and nearly all points are visited. For high-dimensional vector search, approximate nearest neighbor methods like LSH or HNSW are preferred.
Q5. What are the implementation complexity and use-case differences between Segment Tree and Fenwick Tree (BIT)?
Answer: Segment Tree is more complex to implement but general-purpose (range updates, arbitrary aggregation); Fenwick Tree is simpler and faster but limited primarily to prefix sums and point updates.
Explanation: The Fenwick Tree (BIT) uses bitwise operations on the lowest set bit, making it very concise and cache-friendly. It excels at prefix sum queries and point updates. The Segment Tree requires more code but supports range minimum/maximum queries, lazy propagation for range updates, and complex aggregation functions (GCD, XOR, etc.). A practical rule in competitive programming: "use BIT for range sums only; use Segment Tree for everything else."
Summary
Algorithms and data structures form the bedrock of AI engineering.
- Complexity analysis is essential for predicting bottlenecks and making scaling decisions in system design.
- Graph algorithms are directly applied in knowledge graphs, recommendation systems, and path optimization.
- Dynamic programming connects deeply to sequence modeling and the Bellman equation in reinforcement learning.
- k-d trees, LSH, and Beam Search are core algorithms powering efficient inference in modern ML systems.
Combine consistent problem-solving practice with hands-on implementation to master both theory and real-world application.