diff --git a/assignments/pykido/week3/compare_chunking.py b/assignments/pykido/week3/compare_chunking.py
new file mode 100644
index 0000000..ff57796
--- /dev/null
+++ b/assignments/pykido/week3/compare_chunking.py
@@ -0,0 +1,32 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+
+from rag import STRATEGIES
+from rag.retriever import retrieve
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+
+COMPARE_QUERIES = [
+ "답이 단조성을 가질 때 답 자체를 이분 탐색하는 최적화 문제",
+ "연속 구간에서 모든 종류를 포함하는 가장 짧은 구간 찾기",
+ "작업을 소요 시간이 짧은 것부터 처리해 평균 대기를 줄이는 스케줄링",
+ "최소 비용으로 모든 노드를 연결하는 최소 신장 트리",
+]
+
+
+def compare(query: str, k: int = 3) -> None:
+ print("=" * 90)
+ print(f"QUERY: {query}")
+ for strategy in STRATEGIES:
+ print(f"\n[{strategy}]")
+ for i, doc in enumerate(retrieve(query, strategy=strategy, k=k), start=1):
+ preview = doc.page_content[:80].replace("\n", " ")
+ print(f" {i}. {doc.metadata['chunk_id']:<28} | {preview}")
+
+
+if __name__ == "__main__":
+ for query in COMPARE_QUERIES:
+ compare(query)
+ print()
diff --git a/assignments/pykido/week3/data/patterns/backtracking.md b/assignments/pykido/week3/data/patterns/backtracking.md
new file mode 100644
index 0000000..5374966
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/backtracking.md
@@ -0,0 +1,40 @@
+# 백트래킹 (Backtracking)
+
+## 개념
+가능한 모든 후보를 트리 형태로 탐색하되, 현재 경로가 답이 될 수 없다고 판단되면 더 내려가지 않고 즉시 되돌아오는(가지치기, pruning) DFS 기반 기법이다. 완전 탐색의 일종이지만 유망하지 않은 분기를 잘라내 실제 탐색량을 크게 줄인다.
+
+## 언제 쓰나
+- 순열, 조합, 부분집합 생성
+- N-Queen, 스도쿠처럼 제약을 만족하는 배치 찾기
+- 합/경우의 수를 만드는 모든 방법 탐색
+
+## 시간 복잡도
+최악의 경우 후보 공간 전체를 보므로 지수 시간 (`O(2^N)`, `O(N!)` 등)이다. 가지치기가 효과적일수록 실제 비용은 줄지만 상한은 변하지 않는다.
+
+## 기본 템플릿
+```python
+def subsets(nums):
+ result = []
+ path = []
+
+ def backtrack(start):
+ result.append(path[:])
+ for i in range(start, len(nums)):
+ path.append(nums[i])
+ backtrack(i + 1)
+ path.pop()
+
+ backtrack(0)
+ return result
+```
+
+## 흔한 실수
+- 경로에 추가한 뒤 되돌릴 때 `pop` 을 빠뜨려 상태가 오염된다. append 와 pop 은 짝을 이뤄야 한다.
+- 결과를 저장할 때 `path` 를 그대로 참조로 넣어 이후 변경이 반영된다. `path[:]` 로 복사해야 한다.
+- 가지치기 조건을 너무 늦게 검사해 불필요한 깊은 탐색을 한다.
+- 메모이제이션이 가능한 문제인데 순수 백트래킹으로만 풀어 시간 초과가 난다.
+
+## 연관 문제
+- 타겟 넘버 (각 수에 +/- 를 붙이는 모든 경우)
+- N-Queen
+- 부분집합의 합
diff --git a/assignments/pykido/week3/data/patterns/bfs-dfs.md b/assignments/pykido/week3/data/patterns/bfs-dfs.md
new file mode 100644
index 0000000..5e57c07
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/bfs-dfs.md
@@ -0,0 +1,48 @@
+# 너비/깊이 우선 탐색 (BFS / DFS)
+
+## 개념
+그래프나 격자를 체계적으로 순회하는 두 가지 기본 전략이다. **BFS** 는 큐를 사용해 시작점에서 가까운 노드부터 동심원처럼 퍼져 나간다. **DFS** 는 스택(또는 재귀)을 사용해 한 경로를 끝까지 파고든 뒤 되돌아온다. 둘 다 방문 집합으로 중복 방문을 막는다.
+
+## 언제 쓰나
+- 도달 가능 여부, 연결 요소 개수: BFS / DFS 모두 가능
+- 가중치가 모두 1인 그래프의 최단 거리: BFS (처음 방문하는 시점이 최단)
+- 모든 경로 탐색, 백트래킹, 사이클 검출: DFS
+- 격자에서 영역 채우기(flood fill): BFS / DFS 모두 가능
+
+## 시간 복잡도
+정점 V, 간선 E 에 대해 `O(V + E)`. 격자에서는 칸 수에 비례한다.
+
+## 기본 템플릿
+```python
+from collections import deque
+
+def bfs(graph, start):
+ visited = {start}
+ q = deque([start])
+ while q:
+ node = q.popleft()
+ for nxt in graph[node]:
+ if nxt not in visited:
+ visited.add(nxt)
+ q.append(nxt)
+ return visited
+```
+
+DFS 재귀 형태:
+```python
+def dfs(graph, node, visited):
+ visited.add(node)
+ for nxt in graph[node]:
+ if nxt not in visited:
+ dfs(graph, nxt, visited)
+```
+
+## 흔한 실수
+- 방문 처리를 큐에서 꺼내는 시점에 해서 같은 노드를 여러 번 큐에 넣는다. push 시점에 방문 표시해야 한다.
+- BFS 가 아닌 DFS 로 최단 거리를 구하려 한다. 가중치 1 최단 거리는 BFS 가 맞다.
+- DFS 재귀 깊이가 1000 을 넘어 `RecursionError` 가 난다. `sys.setrecursionlimit` 또는 명시적 스택으로 전환한다.
+
+## 연관 문제
+- 타겟 넘버 (각 원소에 +/- 분기, DFS 백트래킹)
+- 격자 영역 개수 세기 (flood fill)
+- 미로 최단 거리 (BFS)
diff --git a/assignments/pykido/week3/data/patterns/binary-search.md b/assignments/pykido/week3/data/patterns/binary-search.md
new file mode 100644
index 0000000..49295ab
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/binary-search.md
@@ -0,0 +1,50 @@
+# 이분 탐색 (Binary Search)
+
+## 개념
+정렬된 배열에서 탐색 범위를 절반씩 줄여가며 목표값을 찾는 기법이다. 매 단계에서 후보 구간의 중앙값을 확인하고, 목표와의 대소 관계로 한쪽 절반을 버린다. 핵심 전제는 **단조성(monotonicity)** 으로, 구간이 정렬되어 있거나 어떤 결정 함수가 한 방향으로만 변할 때만 적용할 수 있다.
+
+## 언제 쓰나
+- 정렬된 배열에서 특정 값 또는 그 삽입 위치를 찾을 때
+- "조건을 만족하는 최소/최대 값"을 구하는 최적화 문제 (parametric search). 답 후보 x에 대해 `가능한가?(x)`가 단조 boolean이면 답 자체를 이분 탐색한다.
+- lower bound / upper bound 가 필요할 때는 직접 구현보다 `bisect_left`, `bisect_right` 를 쓰는 것이 안전하다.
+
+## 시간 복잡도
+탐색 구간이 매 단계 절반이 되므로 `O(log N)`. parametric search 는 결정 함수 비용이 `O(f)` 일 때 `O(f log(범위))` 가 된다.
+
+## 기본 템플릿
+```python
+def binary_search(arr, target):
+ lo, hi = 0, len(arr) - 1
+ while lo <= hi:
+ mid = (lo + hi) // 2
+ if arr[mid] == target:
+ return mid
+ elif arr[mid] < target:
+ lo = mid + 1
+ else:
+ hi = mid - 1
+ return -1
+```
+
+parametric search 형태:
+```python
+def min_feasible(lo, hi, feasible):
+ while lo < hi:
+ mid = (lo + hi) // 2
+ if feasible(mid):
+ hi = mid
+ else:
+ lo = mid + 1
+ return lo
+```
+
+## 흔한 실수
+- `lo <= hi` 와 `lo < hi` 를 혼동한다. 닫힌 구간 인덱스 탐색은 `lo <= hi` 가 안전하다.
+- parametric search 에서 단조성 증명을 건너뛴다. 단조가 아니면 이분 탐색은 오답을 낸다.
+- 탐색 범위 hi 를 너무 작게 잡아 답을 놓친다. 범위는 답이 존재할 수 있는 최댓값까지 충분히 크게 잡는다.
+- lower bound 와 upper bound 가 모호할 때 직접 구현하다 off-by-one 을 낸다.
+
+## 연관 문제
+- 입국심사 (parametric search, 답이 시간에 대해 단조)
+- 징검다리 (최소 점프 거리를 이분 탐색)
+- 정렬된 배열에서의 값 존재 여부 / 개수 세기
diff --git a/assignments/pykido/week3/data/patterns/dijkstra.md b/assignments/pykido/week3/data/patterns/dijkstra.md
new file mode 100644
index 0000000..d420659
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/dijkstra.md
@@ -0,0 +1,43 @@
+# 다익스트라 (Dijkstra)
+
+## 개념
+음수 가중치가 없는 그래프에서 한 시작점으로부터 모든 정점까지의 최단 거리를 구하는 알고리즘이다. 아직 확정되지 않은 정점 중 거리가 가장 짧은 것을 우선순위 큐로 꺼내 확정하고, 그 정점을 거쳐 가는 경로로 인접 정점의 거리를 갱신(relaxation)한다. 한 번 확정된 정점의 거리는 다시 바뀌지 않는다.
+
+## 언제 쓰나
+- 가중치가 있는(음수 없음) 그래프의 단일 출발점 최단 경로
+- 지도 길찾기, 네트워크 지연 최소화
+- 가중치가 다양한 격자 이동 비용 최소화
+
+## 시간 복잡도
+우선순위 큐 구현 기준 `O((V + E) log V)`. 간선마다 최대 한 번 힙에 들어간다.
+
+## 기본 템플릿
+```python
+import heapq
+
+def dijkstra(graph, start, n):
+ dist = [float("inf")] * n
+ dist[start] = 0
+ heap = [(0, start)]
+ while heap:
+ d, node = heapq.heappop(heap)
+ if d > dist[node]:
+ continue
+ for nxt, w in graph[node]:
+ nd = d + w
+ if nd < dist[nxt]:
+ dist[nxt] = nd
+ heapq.heappush(heap, (nd, nxt))
+ return dist
+```
+
+## 흔한 실수
+- 음수 가중치 그래프에 적용한다. 음수 간선이 있으면 벨만-포드를 써야 한다.
+- 힙에서 꺼낸 거리가 이미 확정된 값보다 크면 건너뛰어야 하는데(`d > dist[node]`) 이 검사를 빠뜨려 중복 처리로 느려진다.
+- 거리 배열 초기화를 0 이 아닌 무한대로 해야 하는데 0 으로 두어 갱신이 안 된다.
+- 방문 배열 없이 단순 BFS 로 가중치 최단 경로를 구하려 한다.
+
+## 연관 문제
+- 가중 그래프 최단 경로
+- 배달 (여러 목적지까지 최단 거리)
+- 최소 비용 격자 이동
diff --git a/assignments/pykido/week3/data/patterns/dp.md b/assignments/pykido/week3/data/patterns/dp.md
new file mode 100644
index 0000000..202c405
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/dp.md
@@ -0,0 +1,50 @@
+# 동적 계획법 (Dynamic Programming)
+
+## 개념
+큰 문제를 겹치는 작은 부분 문제로 나누고, 부분 문제의 답을 저장해 재사용하는 기법이다. 두 가지 전제가 모두 성립해야 한다. **최적 부분 구조** (큰 문제의 최적해가 부분 문제의 최적해로 구성됨) 와 **중복 부분 문제** (같은 부분 문제가 여러 번 등장함). 구현은 재귀 + 메모이제이션의 top-down 과 반복문의 bottom-up 두 가지가 있다.
+
+## 언제 쓰나
+- 경우의 수 세기 (경로 수, 조합 수)
+- 최소 비용 / 최대 가치 최적화 (배낭, 동전 교환)
+- 부분 수열 문제 (최장 증가 부분 수열, 편집 거리)
+
+## 시간 복잡도
+일반적으로 `상태 개수 × 상태당 전이 비용`. 격자 DP 는 보통 `O(M*N)`, 배낭은 `O(N*W)` 이다.
+
+## 기본 템플릿
+bottom-up 격자 DP:
+```python
+def grid_paths(m, n, blocked):
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
+ dp[1][1] = 0 if (1, 1) in blocked else 1
+ for r in range(1, m + 1):
+ for c in range(1, n + 1):
+ if (r, c) in blocked:
+ dp[r][c] = 0
+ continue
+ if (r, c) != (1, 1):
+ dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
+ return dp[m][n]
+```
+
+top-down 메모이제이션:
+```python
+from functools import lru_cache
+
+@lru_cache(maxsize=None)
+def fib(n):
+ if n < 2:
+ return n
+ return fib(n - 1) + fib(n - 2)
+```
+
+## 흔한 실수
+- 상태 정의가 모호해 필요한 변수가 누락된다. "dp[i] 가 무엇을 의미하는가"를 한 문장으로 적을 수 있어야 한다.
+- top-down 에서 메모이제이션을 빠뜨려 지수 시간으로 퇴화한다.
+- 경우의 수 문제에서 모듈러 연산을 매 갱신마다 적용하지 않아 오버플로 또는 오답이 난다.
+- 1-based 와 0-based 인덱싱을 섞어 경계에서 틀린다.
+
+## 연관 문제
+- 등굣길 (격자 경로 수, 물웅덩이 회피, mod 1e9+7)
+- 최장 증가 부분 수열
+- 0/1 배낭
diff --git a/assignments/pykido/week3/data/patterns/greedy.md b/assignments/pykido/week3/data/patterns/greedy.md
new file mode 100644
index 0000000..c0ea51a
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/greedy.md
@@ -0,0 +1,35 @@
+# 그리디 (Greedy)
+
+## 개념
+매 단계에서 그 순간 가장 좋아 보이는 선택을 하고, 그 선택을 번복하지 않는 기법이다. 핵심은 **국소 최적 선택이 전역 최적해로 이어진다는 보장**이 있어야 한다는 점이다. 이 보장은 교환 논법(exchange argument)이나 매트로이드 성질로 증명한다. 증명 없이 적용하면 반례에서 무너진다.
+
+## 언제 쓰나
+- 회의실 배정처럼 끝나는 시간이 빠른 것부터 고르는 활동 선택 문제
+- 거스름돈 문제(동전 체계가 정준일 때)
+- 최소 신장 트리(Kruskal, Prim)
+
+## 시간 복잡도
+대개 정렬이 지배해 `O(N log N)`. 정렬 후 한 번의 선형 순회로 답을 만든다.
+
+## 기본 템플릿
+```python
+def max_non_overlapping(intervals):
+ intervals.sort(key=lambda x: x[1])
+ count = 0
+ end = float("-inf")
+ for s, e in intervals:
+ if s >= end:
+ count += 1
+ end = e
+ return count
+```
+
+## 흔한 실수
+- 그리디 선택이 최적임을 증명하지 않고 직관만으로 적용한다. DP 가 정답인 문제에 그리디를 써서 틀린다.
+- 정렬 기준을 잘못 잡는다. 활동 선택은 시작 시간이 아니라 끝나는 시간 기준 정렬이다.
+- 한 번 한 선택을 되돌려야 최적인 문제(분할 가능 배낭이 아닌 0/1 배낭)에 그리디를 적용한다.
+
+## 연관 문제
+- 섬 연결하기 (간선 비용 오름차순 그리디 선택 = Kruskal MST)
+- 회의실 배정
+- 분할 가능 배낭
diff --git a/assignments/pykido/week3/data/patterns/hash-map.md b/assignments/pykido/week3/data/patterns/hash-map.md
new file mode 100644
index 0000000..c56b372
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/hash-map.md
@@ -0,0 +1,43 @@
+# 해시 맵 (Hash Map)
+
+## 개념
+키를 해시 함수로 버킷에 분산시켜 평균 `O(1)` 에 삽입/조회/삭제를 지원하는 자료구조다. 파이썬의 `dict` 와 `set` 이 이에 해당한다. "이미 본 적 있는가", "몇 번 등장했는가"를 빠르게 묻는 거의 모든 문제의 기본 도구다.
+
+## 언제 쓰나
+- 등장 횟수 세기, 빈도 집계 (`collections.Counter`)
+- 두 수의 합처럼 "보수가 존재하는가"를 즉시 확인할 때
+- 중복 제거, 멤버십 검사
+- 그룹화 (애너그램 묶기 등)
+
+## 시간 복잡도
+평균 삽입/조회 `O(1)`, 최악(해시 충돌 다발) `O(N)`. N 개 원소 처리에 전체 `O(N)`.
+
+## 기본 템플릿
+```python
+def two_sum(nums, target):
+ seen = {}
+ for i, x in enumerate(nums):
+ if target - x in seen:
+ return (seen[target - x], i)
+ seen[x] = i
+ return None
+```
+
+빈도 집계:
+```python
+from collections import Counter
+
+def most_common_char(s):
+ counter = Counter(s)
+ return counter.most_common(1)[0][0]
+```
+
+## 흔한 실수
+- 리스트나 딕셔너리처럼 변경 가능한(unhashable) 객체를 키로 쓴다. 튜플로 변환해야 한다.
+- 슬라이딩 윈도우와 함께 쓸 때 빠지는 원소의 카운트를 0 으로 만든 뒤 키 삭제를 빼먹어 메모리/로직이 꼬인다.
+- 정렬이 필요한 결과를 dict 순회 순서에 의존한다. 삽입 순서는 보장되지만 값 기준 정렬은 별도로 해야 한다.
+
+## 연관 문제
+- 보석 쇼핑 (윈도우 내 보석 종류 카운트를 dict 로 추적)
+- 두 수의 합
+- 애너그램 그룹화
diff --git a/assignments/pykido/week3/data/patterns/heap.md b/assignments/pykido/week3/data/patterns/heap.md
new file mode 100644
index 0000000..ff4eb08
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/heap.md
@@ -0,0 +1,55 @@
+# 힙 / 우선순위 큐 (Heap / Priority Queue)
+
+## 개념
+삽입과 최솟값(또는 최댓값) 추출을 모두 `O(log N)` 에 처리하는 완전 이진 트리 기반 자료구조다. 파이썬 `heapq` 는 최소 힙만 제공하므로 최대 힙이 필요하면 값에 음수를 취해 넣는다. 항상 "다음으로 처리할 가장 우선순위 높은 원소"를 빠르게 꺼내야 하는 상황에서 쓴다.
+
+## 언제 쓰나
+- 매 순간 최소/최대 원소를 꺼내야 할 때 (작업 스케줄링)
+- 다익스트라 최단 경로의 우선순위 큐
+- 상위 K 개 원소 유지 (크기 K 힙)
+- 여러 정렬된 리스트의 병합
+
+## 시간 복잡도
+삽입/추출 각각 `O(log N)`, 최소값 확인은 `O(1)`. 리스트로부터 힙 생성(`heapify`)은 `O(N)`.
+
+## 기본 템플릿
+```python
+import heapq
+
+def k_smallest(nums, k):
+ heap = []
+ for x in nums:
+ heapq.heappush(heap, x)
+ return [heapq.heappop(heap) for _ in range(k)]
+```
+
+작업 스케줄링 골격:
+```python
+import heapq
+
+def schedule(jobs):
+ jobs.sort()
+ heap = []
+ time = idx = total = 0
+ while idx < len(jobs) or heap:
+ while idx < len(jobs) and jobs[idx][0] <= time:
+ heapq.heappush(heap, jobs[idx][1])
+ idx += 1
+ if heap:
+ duration = heapq.heappop(heap)
+ time += duration
+ total += time # 단순화한 형태
+ else:
+ time = jobs[idx][0]
+ return total
+```
+
+## 흔한 실수
+- 최대 힙을 직접 구현하려다 실수한다. 음수 부호 트릭이 간단하다.
+- 힙의 임의 위치 원소를 직접 수정하면 힙 불변식이 깨진다. 갱신이 필요하면 lazy deletion 을 쓴다.
+- 튜플을 넣을 때 첫 원소가 동률이면 두 번째 원소로 비교가 넘어간다. 비교 불가능한 객체를 두 번째에 두면 오류가 난다.
+
+## 연관 문제
+- 디스크 컨트롤러 (요청을 도착 시간순으로 보며 작업 시간이 짧은 것을 힙에서 우선 처리)
+- 다익스트라
+- 상위 K 빈도 원소
diff --git a/assignments/pykido/week3/data/patterns/prefix-sum.md b/assignments/pykido/week3/data/patterns/prefix-sum.md
new file mode 100644
index 0000000..c27fbe3
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/prefix-sum.md
@@ -0,0 +1,49 @@
+# 누적 합 (Prefix Sum)
+
+## 개념
+배열의 앞에서부터 누적한 합을 미리 계산해 두면, 임의 구간 `[i, j]` 의 합을 `prefix[j+1] - prefix[i]` 로 `O(1)` 에 구할 수 있다. 구간 합을 여러 번 질의해야 하는 상황에서 매번 다시 더하는 `O(N)` 작업을 `O(1)` 로 줄인다. 2차원으로 확장하면 부분 직사각형 합도 `O(1)` 에 얻는다.
+
+## 언제 쓰나
+- 구간 합을 여러 번 질의할 때
+- "합이 K 인 부분 배열의 개수" (누적 합 + 해시맵)
+- 2차원 격자에서 부분 직사각형 합
+
+## 시간 복잡도
+누적 합 전처리 `O(N)`, 이후 각 구간 질의 `O(1)`. 2차원은 전처리 `O(M*N)`, 질의 `O(1)`.
+
+## 기본 템플릿
+```python
+def build_prefix(nums):
+ prefix = [0] * (len(nums) + 1)
+ for i, x in enumerate(nums):
+ prefix[i + 1] = prefix[i] + x
+ return prefix
+
+def range_sum(prefix, i, j):
+ return prefix[j + 1] - prefix[i]
+```
+
+합이 K 인 부분 배열 개수:
+```python
+from collections import defaultdict
+
+def subarray_sum_k(nums, k):
+ count = total = 0
+ seen = defaultdict(int)
+ seen[0] = 1
+ for x in nums:
+ total += x
+ count += seen[total - k]
+ seen[total] += 1
+ return count
+```
+
+## 흔한 실수
+- prefix 배열 크기를 N 으로 잡아 경계 인덱스에서 틀린다. 보통 `N+1` 로 두고 prefix[0]=0 으로 시작하면 깔끔하다.
+- 구간 합 공식에서 `prefix[j] - prefix[i]` 처럼 off-by-one 을 낸다.
+- 음수가 섞인 배열에서 "합이 K" 문제를 슬라이딩 윈도우로 풀려 한다. 음수가 있으면 누적 합 + 해시맵이 맞다.
+
+## 연관 문제
+- 합이 K 인 부분 배열의 개수
+- 구간 합 질의
+- 2차원 부분 행렬 합
diff --git a/assignments/pykido/week3/data/patterns/sliding-window.md b/assignments/pykido/week3/data/patterns/sliding-window.md
new file mode 100644
index 0000000..fdfbedd
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/sliding-window.md
@@ -0,0 +1,50 @@
+# 슬라이딩 윈도우 (Sliding Window)
+
+## 개념
+배열이나 문자열의 **연속 구간**을 두 포인터(left, right)로 표현하고, right 를 늘려 구간을 확장하거나 left 를 늘려 구간을 축소하면서 구간 통계를 유지하는 기법이다. 매번 구간을 새로 계산하지 않고 들어오고 나가는 원소만 갱신하므로 중첩 반복을 한 번의 선형 순회로 바꾼다.
+
+## 언제 쓰나
+- "길이 K 인 연속 구간의 합/최댓값" 같은 고정 크기 윈도우
+- "조건을 만족하는 가장 긴/짧은 연속 구간" 같은 가변 크기 윈도우
+- 부분 문자열 문제에서 문자 빈도를 해시맵으로 추적할 때
+
+## 시간 복잡도
+left 와 right 가 각각 배열을 한 번씩만 지나가므로 전체 `O(N)`. 윈도우 내부 통계를 `O(1)` 로 갱신하는 것이 관건이다.
+
+## 기본 템플릿
+```python
+def longest_unique_substring(s):
+ seen = {}
+ left = best = 0
+ for right, c in enumerate(s):
+ if c in seen and seen[c] >= left:
+ left = seen[c] + 1
+ seen[c] = right
+ best = max(best, right - left + 1)
+ return best
+```
+
+가변 윈도우 축소 형태:
+```python
+def shortest_subarray_at_least(nums, target):
+ left = total = 0
+ best = float("inf")
+ for right, x in enumerate(nums):
+ total += x
+ while total >= target:
+ best = min(best, right - left + 1)
+ total -= nums[left]
+ left += 1
+ return best if best != float("inf") else 0
+```
+
+## 흔한 실수
+- left 갱신을 빠뜨려 윈도우가 좁혀지지 않는다.
+- 윈도우에서 빠지는 원소의 통계를 반영하지 않아 해시맵이 누수된다.
+- 가변 윈도우에서 축소 조건을 `if` 로 써서 한 칸만 줄인다. 최소 구간을 찾으려면 `while` 로 끝까지 좁혀야 한다.
+- 매 step 마다 `sum()` 을 다시 호출해 `O(N^2)` 로 만든다.
+
+## 연관 문제
+- 보석 쇼핑 (모든 종류를 포함하는 최소 구간, 가변 윈도우 + 해시맵)
+- 고정 길이 부분합의 최댓값
+- 중복 없는 가장 긴 부분 문자열
diff --git a/assignments/pykido/week3/data/patterns/two-pointers.md b/assignments/pykido/week3/data/patterns/two-pointers.md
new file mode 100644
index 0000000..f13c499
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/two-pointers.md
@@ -0,0 +1,37 @@
+# 투 포인터 (Two Pointers)
+
+## 개념
+두 개의 인덱스를 이동시키며 배열을 한 번의 순회로 처리하는 기법이다. 대표적으로 정렬된 배열의 양 끝에서 안쪽으로 좁혀가는 **대향 포인터**와, 같은 방향으로 서로 다른 속도로 이동하는 **동방향 포인터**가 있다. 슬라이딩 윈도우는 동방향 투 포인터의 특수한 형태로 볼 수 있다.
+
+## 언제 쓰나
+- 정렬된 배열에서 합이 특정 값이 되는 쌍을 찾을 때
+- 두 개의 정렬된 리스트를 병합하거나 교집합을 구할 때
+- 양쪽 끝에서 좁혀가며 최적 구간을 찾는 문제
+
+## 시간 복잡도
+각 포인터가 배열을 한 번씩만 지나가므로 `O(N)`. 단, 입력이 정렬되어 있어야 하는 경우 정렬 비용 `O(N log N)` 이 추가된다.
+
+## 기본 템플릿
+```python
+def two_sum_sorted(arr, target):
+ lo, hi = 0, len(arr) - 1
+ while lo < hi:
+ s = arr[lo] + arr[hi]
+ if s == target:
+ return (lo, hi)
+ elif s < target:
+ lo += 1
+ else:
+ hi -= 1
+ return None
+```
+
+## 흔한 실수
+- 정렬되지 않은 배열에 대향 포인터를 적용한다. 단조성이 깨져 오답이 나온다.
+- 포인터 이동 조건을 반대로 작성해 무한 루프에 빠진다.
+- `lo < hi` 와 `lo <= hi` 를 혼동해 같은 원소를 두 번 쓰거나 누락한다.
+
+## 연관 문제
+- 두 큐 합 같게 만들기 (두 큐를 한 덱으로 합치고 양쪽 포인터처럼 pop/push)
+- 정렬된 두 배열의 병합
+- 세 수의 합 (한 원소를 고정하고 나머지를 투 포인터로)
diff --git a/assignments/pykido/week3/data/patterns/union-find.md b/assignments/pykido/week3/data/patterns/union-find.md
new file mode 100644
index 0000000..79525f1
--- /dev/null
+++ b/assignments/pykido/week3/data/patterns/union-find.md
@@ -0,0 +1,46 @@
+# 유니온 파인드 (Union-Find / Disjoint Set)
+
+## 개념
+원소들을 서로소 집합으로 관리하며 "두 원소가 같은 집합에 속하는가"(find)와 "두 집합을 합치기"(union)를 거의 상수 시간에 처리하는 자료구조다. 각 원소가 자기 집합의 대표(루트)를 가리키는 트리로 표현하고, **경로 압축**과 **랭크/크기 기반 합치기** 두 최적화를 함께 적용한다.
+
+## 언제 쓰나
+- 그래프의 연결 요소 개수 세기
+- 사이클 존재 여부 판정
+- 크루스칼 최소 신장 트리에서 간선 추가 시 사이클 검사
+
+## 시간 복잡도
+경로 압축 + union by rank 를 적용하면 연산당 거의 상수, 정확히는 역 애커만 함수 `O(α(N))`. 사실상 `O(1)` 로 봐도 된다.
+
+## 기본 템플릿
+```python
+class DisjointSet:
+ 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, a, b):
+ ra, rb = self.find(a), self.find(b)
+ if ra == rb:
+ return False
+ if self.rank[ra] < self.rank[rb]:
+ ra, rb = rb, ra
+ self.parent[rb] = ra
+ if self.rank[ra] == self.rank[rb]:
+ self.rank[ra] += 1
+ return True
+```
+
+## 흔한 실수
+- 경로 압축을 빼먹어 트리가 한쪽으로 길어지고 find 가 느려진다.
+- union 전에 양쪽의 루트를 비교하지 않고 `parent[a]=b` 처럼 직접 연결해 잘못된 집합을 만든다.
+- 사이클 검사에서 이미 같은 루트인 경우(union 이 False) 처리를 빠뜨린다.
+
+## 연관 문제
+- 섬 연결하기 (크루스칼에서 사이클 방지용으로 union-find 사용)
+- 친구 관계 그룹 수
+- 그래프 연결성 판정
diff --git a/assignments/pykido/week3/data/problems/pgs-118667-two-queues.md b/assignments/pykido/week3/data/problems/pgs-118667-two-queues.md
new file mode 100644
index 0000000..bc925f2
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-118667-two-queues.md
@@ -0,0 +1,51 @@
+# 두 큐 합 같게 만들기 (Programmers Lv.2, pgs-118667)
+
+- 플랫폼: Programmers
+- 레벨: Lv.2
+- 토픽: two-pointers, queue
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/118667
+
+## 문제 요약
+길이가 같은 두 큐 `queue1`, `queue2` 가 주어진다. 한쪽에서 원소를 빼 다른 쪽에 넣는 작업을 반복해 두 큐의 합을 같게 만드는 **최소 작업 횟수**를 구한다. 불가능하면 -1.
+
+## 접근
+두 큐를 덱으로 두고, 한 큐의 합이 더 크면 그 큐의 앞 원소를 빼서 다른 큐의 뒤에 넣으며 두 합을 맞춰 간다(투 포인터처럼 한 방향으로만 옮긴다). 합이 같아지면 종료한다. 전체 합이 홀수면 절대 같아질 수 없으므로 -1. 합 갱신을 `O(1)` 로 하는 것이 핵심이다.
+
+## 복잡도
+`O(N)`. 작업 횟수 상한이 `4 * N` 이내라 그 안에 못 맞추면 -1 로 판정한다.
+
+## 핵심 체크포인트
+- 전체 합이 홀수면 즉시 -1.
+- 두 큐의 합을 매번 재계산하지 말고, 옮기는 원소만큼 `O(1)` 로 갱신한다.
+- 작업 횟수가 `4 * N` 을 넘으면 -1.
+
+## 흔한 실수
+- 매 스텝마다 `sum()` 을 호출해 `O(N^2)` 로 만들어 TLE.
+- `list.pop(0)` 으로 앞에서 빼서 `O(N)` 비용 → 전체 `O(N^2)`. `collections.deque` 를 쓴다.
+- 종료 상한 조건을 빼먹어 같아지지 않는 입력에서 무한 루프.
+
+## 핵심 코드
+```python
+from collections import deque
+
+def solution(queue1, queue2):
+ q1, q2 = deque(queue1), deque(queue2)
+ s1, s2 = sum(q1), sum(q2)
+ if (s1 + s2) % 2:
+ return -1
+
+ for count in range(4 * len(queue1)):
+ if s1 == s2:
+ return count
+ if s1 > s2:
+ x = q1.popleft()
+ s1 -= x
+ s2 += x
+ q2.append(x)
+ else:
+ x = q2.popleft()
+ s2 -= x
+ s1 += x
+ q1.append(x)
+ return -1
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-42627-disk-controller.md b/assignments/pykido/week3/data/problems/pgs-42627-disk-controller.md
new file mode 100644
index 0000000..87ad0fa
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-42627-disk-controller.md
@@ -0,0 +1,47 @@
+# 디스크 컨트롤러 (Programmers Lv.3, pgs-42627)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: heap, greedy
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42627
+
+## 문제 요약
+각 작업이 `[요청 시각, 소요 시간]` 으로 주어진다. 한 번에 하나의 작업만 처리할 수 있고(비선점은 아님, 매 시점 재선택 가능), 각 작업의 **반환 시간(완료 시각 − 요청 시각)의 평균**을 최소화하도록 처리 순서를 정한다. 정수 부분만 반환한다. SJF(Shortest Job First) 스케줄링 문제다.
+
+## 접근
+요청 시각 기준으로 작업을 정렬해 두고, 현재 시각까지 도착한 작업들을 최소 힙(소요 시간 기준)에 넣는다. 매 처리 시점에 힙에서 **소요 시간이 가장 짧은** 작업을 꺼내 실행한다. 힙이 비어 있으면 다음 작업의 요청 시각으로 시간을 점프한다. 짧은 작업을 먼저 처리할수록 뒤에 밀리는 작업들의 대기 시간 합이 줄어든다(그리디).
+
+## 복잡도
+`O(N log N)`. 정렬과 힙 연산이 지배한다.
+
+## 핵심 체크포인트
+- 도착한(요청 시각 <= 현재 시각) 작업만 힙에 넣는다.
+- 힙이 비면 일을 만들지 말고 다음 작업 요청 시각으로 시간을 이동한다.
+- 반환 시간은 `완료 시각 − 요청 시각` 이고, 마지막에 작업 수로 나눠 정수화한다.
+
+## 흔한 실수
+- 단순히 요청 시각 순서대로만 처리해(FCFS) 평균을 최소화하지 못한다. 핵심은 소요 시간 기준 힙이다.
+- 힙이 비었을 때 시간 점프를 빠뜨려 인덱스/시각이 어긋난다.
+- 도착하지 않은 작업까지 힙에 넣어 미래 작업을 미리 실행한다.
+
+## 핵심 코드
+```python
+import heapq
+
+def solution(jobs):
+ jobs.sort()
+ n = len(jobs)
+ time = idx = total = 0
+ heap = []
+ while idx < n or heap:
+ while idx < n and jobs[idx][0] <= time:
+ heapq.heappush(heap, (jobs[idx][1], jobs[idx][0]))
+ idx += 1
+ if heap:
+ duration, request = heapq.heappop(heap)
+ time += duration
+ total += time - request
+ else:
+ time = jobs[idx][0]
+ return total // n
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-42861-connecting-islands.md b/assignments/pykido/week3/data/problems/pgs-42861-connecting-islands.md
new file mode 100644
index 0000000..c7a08d2
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-42861-connecting-islands.md
@@ -0,0 +1,49 @@
+# 섬 연결하기 (Programmers Lv.3, pgs-42861)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: greedy, union-find
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42861
+
+## 문제 요약
+n 개의 섬과 다리 후보들이 `costs = [[섬A, 섬B, 비용], ...]` 로 주어진다. 모든 섬을 연결하는 데 드는 **최소 비용**을 구한다. 전형적인 최소 신장 트리(MST) 문제다.
+
+## 접근
+크루스칼 알고리즘. 모든 간선을 비용 오름차순으로 정렬한 뒤, 가장 싼 간선부터 차례로 본다. 두 섬이 이미 같은 집합(연결됨)이면 추가 시 사이클이 생기므로 건너뛰고, 다른 집합이면 다리를 놓고 두 집합을 합친다(union-find). 간선을 `n-1` 개 채우면 종료한다. "지금 가장 싼 것을 고른다"는 그리디 선택이 MST 에서 최적임이 증명되어 있다.
+
+## 복잡도
+`O(E log E)`. 간선 정렬이 지배하고, union-find 연산은 거의 상수다.
+
+## 핵심 체크포인트
+- 간선을 비용 기준 오름차순 정렬한다.
+- union-find 로 사이클을 검사해 같은 집합이면 건너뛴다.
+- 선택한 간선이 `n-1` 개가 되면 모든 섬이 연결된 것이므로 멈춰도 된다.
+
+## 흔한 실수
+- 사이클 검사를 빼먹고 싼 간선을 무조건 더해 비용이 과다해진다.
+- 프림과 크루스칼을 섞어 구현하다 우선순위 큐와 union-find 를 둘 다 어설프게 쓴다.
+- union-find 에서 경로 압축을 빼 성능이 떨어진다.
+
+## 핵심 코드
+```python
+def solution(n, costs):
+ parent = list(range(n))
+
+ def find(x):
+ while parent[x] != x:
+ parent[x] = parent[parent[x]]
+ x = parent[x]
+ return x
+
+ total = edges = 0
+ for a, b, cost in sorted(costs, key=lambda c: c[2]):
+ ra, rb = find(a), find(b)
+ if ra == rb:
+ continue
+ parent[ra] = rb
+ total += cost
+ edges += 1
+ if edges == n - 1:
+ break
+ return total
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-42898-school-path.md b/assignments/pykido/week3/data/problems/pgs-42898-school-path.md
new file mode 100644
index 0000000..a736e6a
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-42898-school-path.md
@@ -0,0 +1,43 @@
+# 등굣길 (Programmers Lv.3, pgs-42898)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: dp
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42898
+
+## 문제 요약
+m x n 격자에서 (1,1) 에서 출발해 (m,n) 까지 **오른쪽 또는 아래로만** 이동한다. 물에 잠긴 칸 `puddles` 는 지날 수 없다. 가능한 경로의 수를 `1,000,000,007` 로 나눈 나머지로 구한다.
+
+## 접근
+격자 DP. `dp[r][c]` 를 (1,1) 에서 (r,c) 까지의 경로 수로 정의한다. 각 칸으로 오는 방법은 위에서 내려오거나 왼쪽에서 오는 두 가지뿐이므로 `dp[r][c] = dp[r-1][c] + dp[r][c-1]`. 물웅덩이는 0 으로 고정한다.
+
+## 복잡도
+모든 칸을 한 번씩 채우므로 `O(m * n)`.
+
+## 핵심 체크포인트
+- 인덱싱을 1-based 로 통일하면 경계 처리가 간단하다 (`dp` 크기를 `(m+1) x (n+1)`).
+- 물웅덩이를 갱신하기 전에 0 으로 막는다.
+- 매 갱신마다 `% 1_000_000_007` 을 적용한다.
+
+## 흔한 실수
+- DFS 재귀로 풀면서 메모이제이션을 안 해 경로 수가 지수적으로 폭발한다.
+- 모듈러 연산을 마지막에 한 번만 해서 중간 오버플로(다른 언어)나 누적 오류가 난다.
+- 시작 칸 `dp[1][1]` 초기화를 빠뜨린다.
+
+## 핵심 코드
+```python
+def solution(m, n, puddles):
+ MOD = 1_000_000_007
+ blocked = {(c, r) for c, r in puddles}
+ dp = [[0] * (m + 1) for _ in range(n + 1)]
+ dp[1][1] = 1
+ for r in range(1, n + 1):
+ for c in range(1, m + 1):
+ if (c, r) == (1, 1):
+ continue
+ if (c, r) in blocked:
+ dp[r][c] = 0
+ continue
+ dp[r][c] = (dp[r - 1][c] + dp[r][c - 1]) % MOD
+ return dp[n][m]
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-43165-target-number.md b/assignments/pykido/week3/data/problems/pgs-43165-target-number.md
new file mode 100644
index 0000000..2930d0d
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-43165-target-number.md
@@ -0,0 +1,36 @@
+# 타겟 넘버 (Programmers Lv.2, pgs-43165)
+
+- 플랫폼: Programmers
+- 레벨: Lv.2
+- 토픽: bfs-dfs, backtracking
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43165
+
+## 문제 요약
+음이 아닌 정수 배열 `numbers` 의 각 원소 앞에 `+` 또는 `-` 를 붙여 순서대로 더한다. 그 결과가 `target` 이 되는 경우의 수를 구한다. DFS/백트래킹 입문에 적합한 문제다.
+
+## 접근
+각 인덱스 i 에서 현재 누적합에 `+numbers[i]` 와 `-numbers[i]` 두 갈래로 분기하는 DFS 를 돈다. 인덱스가 배열 끝(`i == len(numbers)`)에 도달하면 누적합이 target 인지 검사해 경우의 수를 센다. 상태가 `(i, sum)` 으로 정의되므로 메모이제이션 DP 로도 풀 수 있다.
+
+## 복잡도
+완전 탐색은 `O(2^N)`. `(i, sum)` 상태로 메모이제이션하면 상태 수에 비례하도록 줄일 수 있다.
+
+## 핵심 체크포인트
+- base case 를 명확히: `i == len(numbers)` 일 때 누적합 검사.
+- `+` 와 `-` 두 분기를 모두 재귀 호출한다.
+- 누적합은 인자로 전달해 분기마다 독립적으로 유지한다.
+
+## 흔한 실수
+- DFS 로 충분한데 BFS 로 모든 부분합을 큐에 쌓아 메모리가 폭발한다.
+- 누적합을 리스트 append/pop 의 부수효과로 다뤄 분기 간 상태가 오염된다.
+- base case 에서 인덱스 경계를 잘못 잡아 한 칸 더 들어가거나 덜 들어간다.
+
+## 핵심 코드
+```python
+def solution(numbers, target):
+ def dfs(i, total):
+ if i == len(numbers):
+ return 1 if total == target else 0
+ return dfs(i + 1, total + numbers[i]) + dfs(i + 1, total - numbers[i])
+
+ return dfs(0, 0)
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-43236-stepping-stones.md b/assignments/pykido/week3/data/problems/pgs-43236-stepping-stones.md
new file mode 100644
index 0000000..1081399
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-43236-stepping-stones.md
@@ -0,0 +1,49 @@
+# 징검다리 (Programmers Lv.4, pgs-43236)
+
+- 플랫폼: Programmers
+- 레벨: Lv.4
+- 토픽: binary-search
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43236
+
+## 문제 요약
+시작점과 도착점 사이에 바위들의 좌표 `rocks` 가 주어진다. 바위를 최대 n 개 제거할 수 있을 때, 인접한 지점 사이 거리 중 **가장 짧은 거리의 최댓값**을 구한다.
+
+## 접근
+"최소 점프 거리"가 d 이상이 되도록 만들 수 있는지를 묻는 결정 문제로 바꾼다. 목표 거리 d 가 주어지면, 정렬된 바위를 앞에서부터 보며 직전 유지 지점과의 간격이 d 미만이면 그 바위를 제거한다. 제거 횟수가 n 이하면 d 는 달성 가능하다. d 가 커질수록 제거가 더 많이 필요하므로 단조성이 성립한다. 달성 가능한 **최대 d** 를 이분 탐색한다.
+
+## 복잡도
+`O(R log(distance))`, R = len(rocks). 결정 함수가 `O(R)`, 탐색 범위가 전체 거리다.
+
+## 핵심 체크포인트
+- 바위를 정렬하고 시작점(0)과 도착점(distance)을 경계로 함께 고려한다.
+- 제거 시뮬레이션은 그리디로, 마지막으로 유지한 위치만 추적한다.
+- 최댓값을 찾는 upper bound 형태이므로 `feasible(mid)` 이면 `lo = mid`, 종료 시 lo 처리에 주의한다.
+
+## 흔한 실수
+- 인접 거리 배열을 정렬하는 잘못된 접근. 정렬하면 위치 정보가 사라져 단조성이 깨진다.
+- d 의 탐색 범위를 좌표값이 아니라 인덱스로 잡는다.
+- 시작점/도착점을 빼먹어 첫/마지막 간격을 놓친다.
+
+## 핵심 코드
+```python
+def solution(distance, rocks, n):
+ rocks = sorted(rocks) + [distance]
+
+ def removable(gap):
+ removed = prev = 0
+ for r in rocks:
+ if r - prev < gap:
+ removed += 1
+ else:
+ prev = r
+ return removed <= n
+
+ lo, hi = 1, distance
+ while lo < hi:
+ mid = (lo + hi + 1) // 2
+ if removable(mid):
+ lo = mid
+ else:
+ hi = mid - 1
+ return lo
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-43238-immigration.md b/assignments/pykido/week3/data/problems/pgs-43238-immigration.md
new file mode 100644
index 0000000..9fc2404
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-43238-immigration.md
@@ -0,0 +1,38 @@
+# 입국심사 (Programmers Lv.3, pgs-43238)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: binary-search
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43238
+
+## 문제 요약
+n 명이 입국 심사를 받아야 하고, 각 심사대의 1명당 처리 시간이 배열 `times` 로 주어진다. 모든 사람이 심사를 마치는 데 걸리는 최소 시간을 구한다. n 이 최대 10억 규모라 선형 접근은 불가능하다.
+
+## 접근
+시간 t 가 주어지면 그 안에 처리 가능한 인원은 `f(t) = sum(t // x for x in times)` 이다. t 가 커질수록 처리 인원도 단조 증가하므로, `f(t) >= n` 을 만족하는 **최소 t** 를 이분 탐색한다. 답 자체를 탐색하는 parametric search 의 전형이다.
+
+## 복잡도
+`O(M log(max(times) * n))`, M = len(times). 결정 함수 `f(t)` 가 `O(M)`, 탐색 범위가 `max(times) * n` 이다.
+
+## 핵심 체크포인트
+- 탐색 범위는 `lo = 1`, `hi = max(times) * n` 으로 충분히 크게 잡는다.
+- 단조성을 명시한다: 시간이 늘면 처리 인원도 늘어난다.
+- `f(mid) >= n` 이면 답 후보를 줄이는 방향(`hi = mid`)으로 lower bound 를 찾는다.
+
+## 흔한 실수
+- `lo = 0` 으로 시작하면 `0 // x == 0` 이라 무한 루프나 잘못된 경계가 생긴다.
+- `hi = max(times)` 로만 두면 사람이 많을 때 답을 못 찾는다. 범위에 n 을 곱해야 한다.
+- 선형 탐색은 n 이 10억 스케일이라 시간 초과(TLE).
+
+## 핵심 코드
+```python
+def solution(n, times):
+ lo, hi = 1, max(times) * n
+ while lo < hi:
+ mid = (lo + hi) // 2
+ if sum(mid // x for x in times) >= n:
+ hi = mid
+ else:
+ lo = mid + 1
+ return lo
+```
diff --git a/assignments/pykido/week3/data/problems/pgs-67258-gem-shopping.md b/assignments/pykido/week3/data/problems/pgs-67258-gem-shopping.md
new file mode 100644
index 0000000..445acdf
--- /dev/null
+++ b/assignments/pykido/week3/data/problems/pgs-67258-gem-shopping.md
@@ -0,0 +1,44 @@
+# 보석 쇼핑 (Programmers Lv.3, pgs-67258)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: sliding-window, hash-map
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/67258
+
+## 문제 요약
+진열대에 보석들이 일렬로 놓여 있고 각 칸의 보석 종류가 `gems` 로 주어진다. **모든 종류의 보석을 하나 이상 포함하는 가장 짧은 연속 구간** [start, end] 를 1-index 로 구한다. 답이 여러 개면 start 가 작은 것.
+
+## 접근
+가변 슬라이딩 윈도우 + 해시맵. right 를 늘리며 윈도우 안 보석 종류별 개수를 dict 로 센다. 윈도우가 전체 종류를 모두 포함하면, left 를 늘려 윈도우를 최대한 좁히며 최소 길이를 갱신한다. left 를 옮길 때 카운트가 0 이 된 종류는 dict 에서 삭제한다.
+
+## 복잡도
+`O(N)`. left, right 가 각각 배열을 한 번씩만 지난다.
+
+## 핵심 체크포인트
+- 전체 보석 종류 수를 미리 `set` 으로 계산해 둔다.
+- left 이동 시 카운트가 0 이 되면 키를 삭제해야 "모든 종류 포함" 판정이 정확하다.
+- 답은 1-index 이므로 `[left + 1, right + 1]` 로 반환한다.
+
+## 흔한 실수
+- left 축소를 `if` 로 한 칸만 줄여 최소 윈도우를 놓친다. `while` 로 끝까지 좁혀야 한다.
+- 종류 개수 비교를 dict 길이 대신 잘못된 변수로 한다.
+- 동일 최소 길이일 때 start 가 더 작은 답으로 갱신해버린다. 더 짧을 때만 갱신한다.
+
+## 핵심 코드
+```python
+def solution(gems):
+ kinds = len(set(gems))
+ window = {}
+ left = 0
+ best = (0, len(gems) - 1)
+ for right, g in enumerate(gems):
+ window[g] = window.get(g, 0) + 1
+ while len(window) == kinds:
+ if right - left < best[1] - best[0]:
+ best = (left, right)
+ window[gems[left]] -= 1
+ if window[gems[left]] == 0:
+ del window[gems[left]]
+ left += 1
+ return [best[0] + 1, best[1] + 1]
+```
diff --git a/assignments/pykido/week3/graph.py b/assignments/pykido/week3/graph.py
new file mode 100644
index 0000000..0f840be
--- /dev/null
+++ b/assignments/pykido/week3/graph.py
@@ -0,0 +1,86 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+from langchain_core.documents import Document
+from langchain_core.messages import HumanMessage, SystemMessage
+from langchain_openai import ChatOpenAI
+from langgraph.graph import END, START, StateGraph
+
+from rag.retriever import retrieve
+from schema import RAGAnswer
+from state import RAGState
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+os.environ["LANGSMITH_TRACING_V2"] = "false"
+
+SYSTEM_PROMPT = """당신은 알고리즘 코딩 테스트를 돕는 학습 코치입니다.
+아래 검색된 문서(context)만 근거로 한국어 마크다운으로 답하세요.
+
+원칙:
+1. context 에 있는 내용만 사용하고, 없으면 모른다고 말할 것
+2. 패턴 이름·복잡도·코드는 context 에 적힌 표현을 따를 것
+3. 답변에 어떤 패턴/문제 근거를 썼는지 자연스럽게 녹일 것
+4. 패턴 키와 코드 식별자는 영어 원형 유지
+"""
+
+DEFAULT_STRATEGY = "markdown"
+TOP_K = 4
+
+model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
+structured_model = model.with_structured_output(RAGAnswer)
+
+
+def format_docs(docs: list[Document]) -> str:
+ blocks = []
+ for i, doc in enumerate(docs, start=1):
+ blocks.append(
+ f"[문서 {i}] source={doc.metadata.get('source')} "
+ f"chunk={doc.metadata.get('chunk_id')}\n{doc.page_content}"
+ )
+ return "\n\n".join(blocks)
+
+
+def collect_sources(docs: list[Document]) -> list[str]:
+ seen, sources = set(), []
+ for doc in docs:
+ source = doc.metadata.get("source")
+ if source and source not in seen:
+ seen.add(source)
+ sources.append(source)
+ return sources
+
+
+def retrieve_node(state: RAGState) -> dict:
+ strategy = state.get("strategy") or DEFAULT_STRATEGY
+ docs = retrieve(state["question"], strategy=strategy, k=TOP_K)
+ return {"strategy": strategy, "documents": docs, "context": format_docs(docs)}
+
+
+def generate_node(state: RAGState) -> dict:
+ messages = [
+ SystemMessage(content=SYSTEM_PROMPT),
+ HumanMessage(content=f"질문: {state['question']}\n\ncontext:\n{state['context']}"),
+ ]
+ answer = structured_model.invoke(messages)
+ final = answer.model_dump()
+ final["sources"] = collect_sources(state["documents"])
+ return {"final_answer": final}
+
+
+def build_graph():
+ builder = StateGraph(RAGState)
+ builder.add_node("retrieve", retrieve_node)
+ builder.add_node("generate", generate_node)
+ builder.add_edge(START, "retrieve")
+ builder.add_edge("retrieve", "generate")
+ builder.add_edge("generate", END)
+ return builder.compile()
+
+
+graph = build_graph()
+
+
+def ask(question: str, strategy: str = DEFAULT_STRATEGY) -> dict:
+ result = graph.invoke({"question": question, "strategy": strategy})
+ return result["final_answer"]
diff --git a/assignments/pykido/week3/rag/__init__.py b/assignments/pykido/week3/rag/__init__.py
new file mode 100644
index 0000000..49f00d1
--- /dev/null
+++ b/assignments/pykido/week3/rag/__init__.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+WEEK3_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = WEEK3_DIR / "data"
+INDEX_DIR = WEEK3_DIR / ".cache"
+
+STRATEGIES = ("recursive", "markdown")
diff --git a/assignments/pykido/week3/rag/indexing.py b/assignments/pykido/week3/rag/indexing.py
new file mode 100644
index 0000000..5b19874
--- /dev/null
+++ b/assignments/pykido/week3/rag/indexing.py
@@ -0,0 +1,31 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+
+from . import STRATEGIES
+from .loader import load_documents
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+from .splitter import split_documents
+from .vectorstore import build_vectorstore, save_vectorstore
+
+
+def build_index(strategy: str) -> int:
+ docs = load_documents()
+ chunks = split_documents(docs, strategy)
+ store = build_vectorstore(chunks)
+ save_vectorstore(store, strategy)
+ return len(chunks)
+
+
+def build_all() -> dict[str, int]:
+ counts = {}
+ for strategy in STRATEGIES:
+ counts[strategy] = build_index(strategy)
+ print(f"[{strategy}] indexed {counts[strategy]} chunks")
+ return counts
+
+
+if __name__ == "__main__":
+ build_all()
diff --git a/assignments/pykido/week3/rag/loader.py b/assignments/pykido/week3/rag/loader.py
new file mode 100644
index 0000000..7c387dd
--- /dev/null
+++ b/assignments/pykido/week3/rag/loader.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+from langchain_community.document_loaders import TextLoader
+from langchain_core.documents import Document
+
+from . import DATA_DIR
+
+
+def load_documents(data_dir: Path = DATA_DIR) -> list[Document]:
+ docs: list[Document] = []
+ for path in sorted(data_dir.rglob("*.md")):
+ loaded = TextLoader(str(path), encoding="utf-8").load()
+ category = path.parent.name
+ for doc in loaded:
+ doc.metadata.update(
+ {
+ "source": str(path.relative_to(data_dir)),
+ "filename": path.name,
+ "stem": path.stem,
+ "category": category,
+ }
+ )
+ docs.append(doc)
+ return docs
+
+
+if __name__ == "__main__":
+ documents = load_documents()
+ print(f"loaded {len(documents)} documents")
+ for doc in documents[:3]:
+ print("-" * 60)
+ print(doc.metadata)
+ print(doc.page_content[:200])
diff --git a/assignments/pykido/week3/rag/retriever.py b/assignments/pykido/week3/rag/retriever.py
new file mode 100644
index 0000000..acb70da
--- /dev/null
+++ b/assignments/pykido/week3/rag/retriever.py
@@ -0,0 +1,21 @@
+from functools import lru_cache
+
+from langchain_core.documents import Document
+
+from .vectorstore import load_vectorstore
+
+
+@lru_cache(maxsize=None)
+def _store(strategy: str):
+ return load_vectorstore(strategy)
+
+
+def retrieve(query: str, strategy: str = "markdown", k: int = 4) -> list[Document]:
+ return _store(strategy).as_retriever(search_kwargs={"k": k}).invoke(query)
+
+
+if __name__ == "__main__":
+ for strategy in ("recursive", "markdown"):
+ print(f"\n[{strategy}]")
+ for doc in retrieve("이분 탐색으로 답을 찾는 최적화 문제", strategy=strategy, k=3):
+ print(f"- {doc.metadata['chunk_id']} ({doc.metadata['source']})")
diff --git a/assignments/pykido/week3/rag/splitter.py b/assignments/pykido/week3/rag/splitter.py
new file mode 100644
index 0000000..7ed96e7
--- /dev/null
+++ b/assignments/pykido/week3/rag/splitter.py
@@ -0,0 +1,53 @@
+from langchain_core.documents import Document
+from langchain_text_splitters import (
+ MarkdownHeaderTextSplitter,
+ RecursiveCharacterTextSplitter,
+)
+
+HEADERS = [("#", "h1"), ("##", "h2"), ("###", "h3")]
+
+
+def split_recursive(
+ docs: list[Document],
+ chunk_size: int = 700,
+ chunk_overlap: int = 100,
+) -> list[Document]:
+ splitter = RecursiveCharacterTextSplitter(
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ separators=["\n\n", "\n", " ", ""],
+ )
+ chunks = splitter.split_documents(docs)
+ for i, chunk in enumerate(chunks):
+ chunk.metadata["chunk_strategy"] = "recursive"
+ chunk.metadata["chunk_id"] = f"{chunk.metadata.get('stem', 'doc')}-rec-{i}"
+ return chunks
+
+
+def split_markdown(docs: list[Document]) -> list[Document]:
+ splitter = MarkdownHeaderTextSplitter(headers_to_split_on=HEADERS, strip_headers=False)
+ chunks: list[Document] = []
+ for doc in docs:
+ for i, section in enumerate(splitter.split_text(doc.page_content)):
+ section.metadata = {**doc.metadata, **section.metadata}
+ section.metadata["chunk_strategy"] = "markdown"
+ section.metadata["chunk_id"] = f"{doc.metadata.get('stem', 'doc')}-md-{i}"
+ chunks.append(section)
+ return chunks
+
+
+def split_documents(docs: list[Document], strategy: str) -> list[Document]:
+ if strategy == "recursive":
+ return split_recursive(docs)
+ if strategy == "markdown":
+ return split_markdown(docs)
+ raise ValueError(f"unknown strategy: {strategy}")
+
+
+if __name__ == "__main__":
+ from .loader import load_documents
+
+ docs = load_documents()
+ for strategy in ("recursive", "markdown"):
+ chunks = split_documents(docs, strategy)
+ print(f"[{strategy}] {len(docs)} docs -> {len(chunks)} chunks")
diff --git a/assignments/pykido/week3/rag/vectorstore.py b/assignments/pykido/week3/rag/vectorstore.py
new file mode 100644
index 0000000..fedea4b
--- /dev/null
+++ b/assignments/pykido/week3/rag/vectorstore.py
@@ -0,0 +1,36 @@
+import shutil
+
+from langchain_community.vectorstores import FAISS
+from langchain_core.documents import Document
+from langchain_openai import OpenAIEmbeddings
+
+from . import INDEX_DIR
+
+EMBEDDING_MODEL = "text-embedding-3-small"
+
+
+def get_embeddings() -> OpenAIEmbeddings:
+ return OpenAIEmbeddings(model=EMBEDDING_MODEL)
+
+
+def build_vectorstore(chunks: list[Document]) -> FAISS:
+ return FAISS.from_documents(chunks, get_embeddings())
+
+
+def save_vectorstore(store: FAISS, strategy: str) -> None:
+ path = INDEX_DIR / strategy
+ if path.exists():
+ shutil.rmtree(path)
+ path.mkdir(parents=True, exist_ok=True)
+ store.save_local(str(path))
+
+
+def load_vectorstore(strategy: str) -> FAISS:
+ path = INDEX_DIR / strategy
+ if not path.exists():
+ raise FileNotFoundError(f"index not found: {path}. run indexing.py first.")
+ return FAISS.load_local(
+ str(path),
+ get_embeddings(),
+ allow_dangerous_deserialization=True,
+ )
diff --git a/assignments/pykido/week3/run.ipynb b/assignments/pykido/week3/run.ipynb
new file mode 100644
index 0000000..e840da1
--- /dev/null
+++ b/assignments/pykido/week3/run.ipynb
@@ -0,0 +1,476 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "f9c39b0e",
+ "metadata": {},
+ "source": [
+ "# Week3 - Domain RAG (알고리즘 코칭)\n",
+ "\n",
+ "week1~2의 알고리즘 코치 도메인을 그대로 이어, 패턴/문제 풀이 문서를 코퍼스로 한 RAG 파이프라인을 만든다.\n",
+ "\n",
+ "- 코퍼스: `data/patterns/*.md` (12) + `data/problems/*.md` (8) = 20개\n",
+ "- 파이프라인: loader → splitter → embedding → FAISS → retriever → 2-step RAG Q&A\n",
+ "- 청킹 전략 2개 비교: recursive vs markdown-header\n",
+ "- 그래프: `retrieve` → `generate` 2-step StateGraph"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "480bdb86",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:12.737459Z",
+ "iopub.status.busy": "2026-06-06T04:31:12.737271Z",
+ "iopub.status.idle": "2026-06-06T04:31:12.745406Z",
+ "shell.execute_reply": "2026-06-06T04:31:12.744990Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "from dotenv import find_dotenv, load_dotenv\n",
+ "\n",
+ "load_dotenv(find_dotenv(), override=True)\n",
+ "os.environ[\"LANGSMITH_TRACING\"] = \"false\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d4b56d78",
+ "metadata": {},
+ "source": [
+ "## 1. 코퍼스 로드\n",
+ "\n",
+ "`data/` 아래 마크다운 20개를 로드하고 카테고리(patterns/problems) 메타데이터를 붙인다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "3dd09633",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:12.746985Z",
+ "iopub.status.busy": "2026-06-06T04:31:12.746882Z",
+ "iopub.status.idle": "2026-06-06T04:31:13.071861Z",
+ "shell.execute_reply": "2026-06-06T04:31:13.071451Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "문서 수: 20\n",
+ "카테고리: {'patterns': 12, 'problems': 8}\n",
+ "예시 메타: {'source': 'patterns/backtracking.md', 'filename': 'backtracking.md', 'stem': 'backtracking', 'category': 'patterns'}\n"
+ ]
+ }
+ ],
+ "source": [
+ "from collections import Counter\n",
+ "from rag.loader import load_documents\n",
+ "\n",
+ "docs = load_documents()\n",
+ "print(f\"문서 수: {len(docs)}\")\n",
+ "print(\"카테고리:\", dict(Counter(d.metadata[\"category\"] for d in docs)))\n",
+ "print(\"예시 메타:\", docs[0].metadata)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "56f27765",
+ "metadata": {},
+ "source": [
+ "## 2. 청킹 전략 비교 (recursive vs markdown-header)\n",
+ "\n",
+ "- **recursive**: 700자 기준 문자 분할. 길이로 자르므로 한 섹션이 쪼개지거나 코드 블록이 본문과 섞인다.\n",
+ "- **markdown**: `#/##/###` 헤더 기준 분할. 개념/복잡도/코드/실수 같은 의미 단위가 보존된다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "670b94d1",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:13.073679Z",
+ "iopub.status.busy": "2026-06-06T04:31:13.073575Z",
+ "iopub.status.idle": "2026-06-06T04:31:13.079546Z",
+ "shell.execute_reply": "2026-06-06T04:31:13.078988Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "recursive: 47 chunks\n",
+ " markdown: 128 chunks\n"
+ ]
+ }
+ ],
+ "source": [
+ "from rag.splitter import split_documents\n",
+ "\n",
+ "for strategy in (\"recursive\", \"markdown\"):\n",
+ " chunks = split_documents(docs, strategy)\n",
+ " print(f\"{strategy:>9}: {len(chunks)} chunks\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7c90c63b",
+ "metadata": {},
+ "source": [
+ "## 3. 인덱싱 (OpenAI 임베딩 + FAISS)\n",
+ "\n",
+ "두 전략 각각 FAISS 인덱스를 만들어 `.cache/` 에 저장한다. (`text-embedding-3-small`)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "e3eb3475",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:13.081205Z",
+ "iopub.status.busy": "2026-06-06T04:31:13.081104Z",
+ "iopub.status.idle": "2026-06-06T04:31:15.769613Z",
+ "shell.execute_reply": "2026-06-06T04:31:15.769013Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "[recursive] indexed 47 chunks\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "[markdown] indexed 128 chunks\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'recursive': 47, 'markdown': 128}"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from rag.indexing import build_all\n",
+ "\n",
+ "build_all()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "34d0a4b1",
+ "metadata": {},
+ "source": [
+ "## 4. 청킹 전략별 검색 결과 비교\n",
+ "\n",
+ "같은 쿼리를 두 전략으로 검색해 상위 3개 청크를 비교한다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "740edf09",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:15.771153Z",
+ "iopub.status.busy": "2026-06-06T04:31:15.771046Z",
+ "iopub.status.idle": "2026-06-06T04:31:17.420156Z",
+ "shell.execute_reply": "2026-06-06T04:31:17.419251Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " query | \n",
+ " recursive top-3 | \n",
+ " markdown top-3 | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 답이 단조성을 가질 때 답 자체를 이분 탐색하는 최적화 문제 | \n",
+ " binary-search-rec-5, binary-search-rec-7, greedy-rec-15 | \n",
+ " binary-search-md-0, pgs-67258-gem-shopping-md-5, greedy-md-4 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 연속 구간에서 모든 종류를 포함하는 가장 짧은 구간 찾기 | \n",
+ " sliding-window-rec-24, binary-search-rec-5, binary-search-rec-7 | \n",
+ " pgs-67258-gem-shopping-md-1, heap-md-1, sliding-window-md-5 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 작업을 소요 시간이 짧은 것부터 처리해 평균 대기를 줄이는 스케줄링 | \n",
+ " heap-rec-19, pgs-42627-disk-controller-rec-31, pgs-42627-disk-controller-rec-32 | \n",
+ " pgs-42627-disk-controller-md-1, pgs-42627-disk-controller-md-2, pgs-42627-disk-controller-md-5 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 최소 비용으로 모든 노드를 연결하는 최소 신장 트리 | \n",
+ " pgs-42861-connecting-islands-rec-34, pgs-42861-connecting-islands-rec-35, pgs-118667-two-queues-rec-29 | \n",
+ " pgs-42861-connecting-islands-md-1, pgs-42861-connecting-islands-md-4, union-find-md-1 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " query \\\n",
+ "0 답이 단조성을 가질 때 답 자체를 이분 탐색하는 최적화 문제 \n",
+ "1 연속 구간에서 모든 종류를 포함하는 가장 짧은 구간 찾기 \n",
+ "2 작업을 소요 시간이 짧은 것부터 처리해 평균 대기를 줄이는 스케줄링 \n",
+ "3 최소 비용으로 모든 노드를 연결하는 최소 신장 트리 \n",
+ "\n",
+ " recursive top-3 \\\n",
+ "0 binary-search-rec-5, binary-search-rec-7, greedy-rec-15 \n",
+ "1 sliding-window-rec-24, binary-search-rec-5, binary-search-rec-7 \n",
+ "2 heap-rec-19, pgs-42627-disk-controller-rec-31, pgs-42627-disk-controller-rec-32 \n",
+ "3 pgs-42861-connecting-islands-rec-34, pgs-42861-connecting-islands-rec-35, pgs-118667-two-queues-rec-29 \n",
+ "\n",
+ " markdown top-3 \n",
+ "0 binary-search-md-0, pgs-67258-gem-shopping-md-5, greedy-md-4 \n",
+ "1 pgs-67258-gem-shopping-md-1, heap-md-1, sliding-window-md-5 \n",
+ "2 pgs-42627-disk-controller-md-1, pgs-42627-disk-controller-md-2, pgs-42627-disk-controller-md-5 \n",
+ "3 pgs-42861-connecting-islands-md-1, pgs-42861-connecting-islands-md-4, union-find-md-1 "
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import pandas as pd\n",
+ "from rag.retriever import retrieve\n",
+ "\n",
+ "COMPARE_QUERIES = [\n",
+ " \"답이 단조성을 가질 때 답 자체를 이분 탐색하는 최적화 문제\",\n",
+ " \"연속 구간에서 모든 종류를 포함하는 가장 짧은 구간 찾기\",\n",
+ " \"작업을 소요 시간이 짧은 것부터 처리해 평균 대기를 줄이는 스케줄링\",\n",
+ " \"최소 비용으로 모든 노드를 연결하는 최소 신장 트리\",\n",
+ "]\n",
+ "\n",
+ "rows = []\n",
+ "for q in COMPARE_QUERIES:\n",
+ " rec = [d.metadata[\"chunk_id\"] for d in retrieve(q, strategy=\"recursive\", k=3)]\n",
+ " mdh = [d.metadata[\"chunk_id\"] for d in retrieve(q, strategy=\"markdown\", k=3)]\n",
+ " rows.append({\"query\": q, \"recursive top-3\": \", \".join(rec), \"markdown top-3\": \", \".join(mdh)})\n",
+ "\n",
+ "pd.set_option(\"display.max_colwidth\", None)\n",
+ "pd.DataFrame(rows)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a7ecdf5c",
+ "metadata": {},
+ "source": [
+ "## 5. 2-step RAG 그래프\n",
+ "\n",
+ "`retrieve` (검색 + context 포맷) → `generate` (structured output 답변 + 근거 문서)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "484ed34f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:17.427322Z",
+ "iopub.status.busy": "2026-06-06T04:31:17.427112Z",
+ "iopub.status.idle": "2026-06-06T04:31:19.431318Z",
+ "shell.execute_reply": "2026-06-06T04:31:19.429972Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from IPython.display import Image\n",
+ "from graph import graph\n",
+ "\n",
+ "Image(graph.get_graph().draw_mermaid_png())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c15500de",
+ "metadata": {},
+ "source": [
+ "## 6. 최종 테스트 질문 (5개+)\n",
+ "\n",
+ "기본 전략은 markdown-header. 답변과 함께 근거 문서(sources), confidence 를 출력한다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "3ddb76a9",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-06-06T04:31:19.433325Z",
+ "iopub.status.busy": "2026-06-06T04:31:19.433212Z",
+ "iopub.status.idle": "2026-06-06T04:31:36.103533Z",
+ "shell.execute_reply": "2026-06-06T04:31:36.102818Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: 입국심사 문제는 왜 이분 탐색으로 푸나요?\n",
+ "A: 입국심사 문제는 이분 탐색을 사용하는 이유는 주어진 심사대의 처리 시간이 단조 증가하는 성질을 가지기 때문입니다. 즉, 특정 시간 내에 모든 사람이 심사를 마칠 수 있는지 여부가 시간에 대해 단조롭게 변하기 때문에, 이분 탐색을 통해 효율적으로 최소 시간을 찾을 수 있습니다. 이 문제는 n이 최대 10억 규모로 매우 크기 때문에 선형 접근은 불가능하며, 이분 탐색을 통해 가능한 최소 시간을 빠르게 찾는 것이 적합합니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['problems/pgs-43238-immigration.md', 'patterns/binary-search.md', 'patterns/hash-map.md']\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: 보석 쇼핑에서 슬라이딩 윈도우를 좁힐 때 while 을 써야 하는 이유는?\n",
+ "A: 보석 쇼핑 문제에서 슬라이딩 윈도우를 좁힐 때 `while`을 사용하는 이유는, `left` 포인터를 한 칸씩 줄이는 `if` 문으로는 최소 윈도우를 놓칠 수 있기 때문입니다. `while` 문을 사용하면 `left` 포인터를 가능한 한 끝까지 이동시켜 윈도우를 최대한 좁힐 수 있습니다. 이렇게 함으로써 모든 보석 종류를 포함하는 최소 길이를 정확히 갱신할 수 있습니다. 이는 슬라이딩 윈도우 기법의 핵심으로, 구간 통계를 유지하면서 효율적으로 문제를 해결하는 데 도움을 줍니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['patterns/hash-map.md', 'problems/pgs-67258-gem-shopping.md', 'patterns/sliding-window.md']\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: 디스크 컨트롤러는 어떤 자료구조로 풀고 핵심 아이디어가 뭔가요?\n",
+ "A: 디스크 컨트롤러 문제는 주로 **힙(Heap)** 자료구조를 사용하여 해결합니다. 이 문제의 핵심 아이디어는 요청을 도착 시간순으로 정렬한 후, 작업 시간이 짧은 요청을 우선적으로 처리하는 것입니다. 이를 통해 전체 작업의 평균 대기 시간을 최소화할 수 있습니다. 따라서, 요청을 처리할 때는 힙을 활용하여 가장 짧은 작업 시간을 가진 요청을 효율적으로 선택하는 방식으로 접근합니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['patterns/heap.md', 'problems/pgs-42627-disk-controller.md', 'patterns/two-pointers.md']\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: 섬 연결하기에서 union-find 는 어떤 역할을 하나요?\n",
+ "A: 섬 연결하기 문제에서 union-find는 사이클을 검사하는 역할을 합니다. 이 알고리즘은 간선을 비용 기준으로 오름차순 정렬한 후, 각 간선을 선택할 때 union-find를 사용하여 두 정점이 같은 집합에 속하는지를 확인합니다. 만약 같은 집합이라면 사이클이 발생하므로 그 간선을 건너뛰고, 그렇지 않다면 두 정점을 연결합니다. 이 과정을 반복하여 선택한 간선의 수가 `n-1`이 되면 모든 섬이 연결된 것으로 판단하고 알고리즘을 종료합니다. 따라서 union-find는 크루스칼 알고리즘에서 사이클 방지를 위한 중요한 역할을 수행합니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['patterns/union-find.md', 'problems/pgs-42861-connecting-islands.md', 'patterns/greedy.md']\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: DFS 로 최단 거리를 구하면 안 되는 이유는?\n",
+ "A: DFS(깊이 우선 탐색)는 최단 거리를 구하는 데 적합하지 않습니다. 그 이유는 DFS가 경로를 탐색할 때, 모든 가능한 경로를 깊이 있게 탐색하기 때문에 최단 경로를 보장하지 않기 때문입니다. 특히, 가중치가 있는 그래프에서 DFS는 최단 경로를 찾는 데 비효율적이며, 가중치가 1인 경우에는 BFS(너비 우선 탐색)가 최단 거리를 구하는 데 적합하다고 알려져 있습니다. 따라서, DFS로 최단 거리를 구하려고 하면 잘못된 결과를 초래할 수 있습니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['patterns/dijkstra.md', 'patterns/bfs-dfs.md', 'problems/pgs-43236-stepping-stones.md', 'problems/pgs-43165-target-number.md']\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "==========================================================================================\n",
+ "Q: 등굣길 문제에서 DP 상태는 어떻게 정의하나요?\n",
+ "A: 등굣길 문제에서 DP 상태는 `dp[r][c]`로 정의되며, 이는 (1,1)에서 (r,c)까지의 경로 수를 나타냅니다. 각 칸으로 오는 방법은 위에서 내려오거나 왼쪽에서 오는 두 가지뿐이므로, 상태 전이는 `dp[r][c] = dp[r-1][c] + dp[r][c-1]`로 표현됩니다. 물웅덩이는 0으로 고정하여 경로 수를 계산할 때 이를 고려해야 합니다.\n",
+ "confidence: 0.9\n",
+ "sources: ['problems/pgs-42898-school-path.md', 'patterns/dp.md', 'patterns/greedy.md']\n"
+ ]
+ }
+ ],
+ "source": [
+ "from graph import ask\n",
+ "\n",
+ "TEST_QUESTIONS = [\n",
+ " \"입국심사 문제는 왜 이분 탐색으로 푸나요?\",\n",
+ " \"보석 쇼핑에서 슬라이딩 윈도우를 좁힐 때 while 을 써야 하는 이유는?\",\n",
+ " \"디스크 컨트롤러는 어떤 자료구조로 풀고 핵심 아이디어가 뭔가요?\",\n",
+ " \"섬 연결하기에서 union-find 는 어떤 역할을 하나요?\",\n",
+ " \"DFS 로 최단 거리를 구하면 안 되는 이유는?\",\n",
+ " \"등굣길 문제에서 DP 상태는 어떻게 정의하나요?\",\n",
+ "]\n",
+ "\n",
+ "for q in TEST_QUESTIONS:\n",
+ " result = ask(q)\n",
+ " print(\"=\" * 90)\n",
+ " print(\"Q:\", q)\n",
+ " print(\"A:\", result[\"answer\"])\n",
+ " print(\"confidence:\", result[\"confidence\"])\n",
+ " print(\"sources:\", result[\"sources\"])"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.10"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/assignments/pykido/week3/schema.py b/assignments/pykido/week3/schema.py
new file mode 100644
index 0000000..bb3ee62
--- /dev/null
+++ b/assignments/pykido/week3/schema.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, Field
+
+
+class RAGAnswer(BaseModel):
+ """검색된 문서에 근거한 RAG 답변 형식."""
+
+ answer: str = Field(description="한국어 마크다운 답변. context 근거만 사용하고 모르면 모른다고 한다.")
+ confidence: float = Field(
+ ge=0.0,
+ le=1.0,
+ description="context 가 질문을 직접 뒷받침하면 0.85+, 부분적 근거면 ≤0.7.",
+ )
diff --git a/assignments/pykido/week3/state.py b/assignments/pykido/week3/state.py
new file mode 100644
index 0000000..f6e8803
--- /dev/null
+++ b/assignments/pykido/week3/state.py
@@ -0,0 +1,11 @@
+from typing import Optional, TypedDict
+
+from langchain_core.documents import Document
+
+
+class RAGState(TypedDict):
+ question: str
+ strategy: str
+ documents: list[Document]
+ context: str
+ final_answer: Optional[dict]
diff --git a/assignments/pykido/week4/data/patterns/backtracking.md b/assignments/pykido/week4/data/patterns/backtracking.md
new file mode 100644
index 0000000..5374966
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/backtracking.md
@@ -0,0 +1,40 @@
+# 백트래킹 (Backtracking)
+
+## 개념
+가능한 모든 후보를 트리 형태로 탐색하되, 현재 경로가 답이 될 수 없다고 판단되면 더 내려가지 않고 즉시 되돌아오는(가지치기, pruning) DFS 기반 기법이다. 완전 탐색의 일종이지만 유망하지 않은 분기를 잘라내 실제 탐색량을 크게 줄인다.
+
+## 언제 쓰나
+- 순열, 조합, 부분집합 생성
+- N-Queen, 스도쿠처럼 제약을 만족하는 배치 찾기
+- 합/경우의 수를 만드는 모든 방법 탐색
+
+## 시간 복잡도
+최악의 경우 후보 공간 전체를 보므로 지수 시간 (`O(2^N)`, `O(N!)` 등)이다. 가지치기가 효과적일수록 실제 비용은 줄지만 상한은 변하지 않는다.
+
+## 기본 템플릿
+```python
+def subsets(nums):
+ result = []
+ path = []
+
+ def backtrack(start):
+ result.append(path[:])
+ for i in range(start, len(nums)):
+ path.append(nums[i])
+ backtrack(i + 1)
+ path.pop()
+
+ backtrack(0)
+ return result
+```
+
+## 흔한 실수
+- 경로에 추가한 뒤 되돌릴 때 `pop` 을 빠뜨려 상태가 오염된다. append 와 pop 은 짝을 이뤄야 한다.
+- 결과를 저장할 때 `path` 를 그대로 참조로 넣어 이후 변경이 반영된다. `path[:]` 로 복사해야 한다.
+- 가지치기 조건을 너무 늦게 검사해 불필요한 깊은 탐색을 한다.
+- 메모이제이션이 가능한 문제인데 순수 백트래킹으로만 풀어 시간 초과가 난다.
+
+## 연관 문제
+- 타겟 넘버 (각 수에 +/- 를 붙이는 모든 경우)
+- N-Queen
+- 부분집합의 합
diff --git a/assignments/pykido/week4/data/patterns/bfs-dfs.md b/assignments/pykido/week4/data/patterns/bfs-dfs.md
new file mode 100644
index 0000000..5e57c07
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/bfs-dfs.md
@@ -0,0 +1,48 @@
+# 너비/깊이 우선 탐색 (BFS / DFS)
+
+## 개념
+그래프나 격자를 체계적으로 순회하는 두 가지 기본 전략이다. **BFS** 는 큐를 사용해 시작점에서 가까운 노드부터 동심원처럼 퍼져 나간다. **DFS** 는 스택(또는 재귀)을 사용해 한 경로를 끝까지 파고든 뒤 되돌아온다. 둘 다 방문 집합으로 중복 방문을 막는다.
+
+## 언제 쓰나
+- 도달 가능 여부, 연결 요소 개수: BFS / DFS 모두 가능
+- 가중치가 모두 1인 그래프의 최단 거리: BFS (처음 방문하는 시점이 최단)
+- 모든 경로 탐색, 백트래킹, 사이클 검출: DFS
+- 격자에서 영역 채우기(flood fill): BFS / DFS 모두 가능
+
+## 시간 복잡도
+정점 V, 간선 E 에 대해 `O(V + E)`. 격자에서는 칸 수에 비례한다.
+
+## 기본 템플릿
+```python
+from collections import deque
+
+def bfs(graph, start):
+ visited = {start}
+ q = deque([start])
+ while q:
+ node = q.popleft()
+ for nxt in graph[node]:
+ if nxt not in visited:
+ visited.add(nxt)
+ q.append(nxt)
+ return visited
+```
+
+DFS 재귀 형태:
+```python
+def dfs(graph, node, visited):
+ visited.add(node)
+ for nxt in graph[node]:
+ if nxt not in visited:
+ dfs(graph, nxt, visited)
+```
+
+## 흔한 실수
+- 방문 처리를 큐에서 꺼내는 시점에 해서 같은 노드를 여러 번 큐에 넣는다. push 시점에 방문 표시해야 한다.
+- BFS 가 아닌 DFS 로 최단 거리를 구하려 한다. 가중치 1 최단 거리는 BFS 가 맞다.
+- DFS 재귀 깊이가 1000 을 넘어 `RecursionError` 가 난다. `sys.setrecursionlimit` 또는 명시적 스택으로 전환한다.
+
+## 연관 문제
+- 타겟 넘버 (각 원소에 +/- 분기, DFS 백트래킹)
+- 격자 영역 개수 세기 (flood fill)
+- 미로 최단 거리 (BFS)
diff --git a/assignments/pykido/week4/data/patterns/binary-search.md b/assignments/pykido/week4/data/patterns/binary-search.md
new file mode 100644
index 0000000..49295ab
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/binary-search.md
@@ -0,0 +1,50 @@
+# 이분 탐색 (Binary Search)
+
+## 개념
+정렬된 배열에서 탐색 범위를 절반씩 줄여가며 목표값을 찾는 기법이다. 매 단계에서 후보 구간의 중앙값을 확인하고, 목표와의 대소 관계로 한쪽 절반을 버린다. 핵심 전제는 **단조성(monotonicity)** 으로, 구간이 정렬되어 있거나 어떤 결정 함수가 한 방향으로만 변할 때만 적용할 수 있다.
+
+## 언제 쓰나
+- 정렬된 배열에서 특정 값 또는 그 삽입 위치를 찾을 때
+- "조건을 만족하는 최소/최대 값"을 구하는 최적화 문제 (parametric search). 답 후보 x에 대해 `가능한가?(x)`가 단조 boolean이면 답 자체를 이분 탐색한다.
+- lower bound / upper bound 가 필요할 때는 직접 구현보다 `bisect_left`, `bisect_right` 를 쓰는 것이 안전하다.
+
+## 시간 복잡도
+탐색 구간이 매 단계 절반이 되므로 `O(log N)`. parametric search 는 결정 함수 비용이 `O(f)` 일 때 `O(f log(범위))` 가 된다.
+
+## 기본 템플릿
+```python
+def binary_search(arr, target):
+ lo, hi = 0, len(arr) - 1
+ while lo <= hi:
+ mid = (lo + hi) // 2
+ if arr[mid] == target:
+ return mid
+ elif arr[mid] < target:
+ lo = mid + 1
+ else:
+ hi = mid - 1
+ return -1
+```
+
+parametric search 형태:
+```python
+def min_feasible(lo, hi, feasible):
+ while lo < hi:
+ mid = (lo + hi) // 2
+ if feasible(mid):
+ hi = mid
+ else:
+ lo = mid + 1
+ return lo
+```
+
+## 흔한 실수
+- `lo <= hi` 와 `lo < hi` 를 혼동한다. 닫힌 구간 인덱스 탐색은 `lo <= hi` 가 안전하다.
+- parametric search 에서 단조성 증명을 건너뛴다. 단조가 아니면 이분 탐색은 오답을 낸다.
+- 탐색 범위 hi 를 너무 작게 잡아 답을 놓친다. 범위는 답이 존재할 수 있는 최댓값까지 충분히 크게 잡는다.
+- lower bound 와 upper bound 가 모호할 때 직접 구현하다 off-by-one 을 낸다.
+
+## 연관 문제
+- 입국심사 (parametric search, 답이 시간에 대해 단조)
+- 징검다리 (최소 점프 거리를 이분 탐색)
+- 정렬된 배열에서의 값 존재 여부 / 개수 세기
diff --git a/assignments/pykido/week4/data/patterns/dijkstra.md b/assignments/pykido/week4/data/patterns/dijkstra.md
new file mode 100644
index 0000000..d420659
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/dijkstra.md
@@ -0,0 +1,43 @@
+# 다익스트라 (Dijkstra)
+
+## 개념
+음수 가중치가 없는 그래프에서 한 시작점으로부터 모든 정점까지의 최단 거리를 구하는 알고리즘이다. 아직 확정되지 않은 정점 중 거리가 가장 짧은 것을 우선순위 큐로 꺼내 확정하고, 그 정점을 거쳐 가는 경로로 인접 정점의 거리를 갱신(relaxation)한다. 한 번 확정된 정점의 거리는 다시 바뀌지 않는다.
+
+## 언제 쓰나
+- 가중치가 있는(음수 없음) 그래프의 단일 출발점 최단 경로
+- 지도 길찾기, 네트워크 지연 최소화
+- 가중치가 다양한 격자 이동 비용 최소화
+
+## 시간 복잡도
+우선순위 큐 구현 기준 `O((V + E) log V)`. 간선마다 최대 한 번 힙에 들어간다.
+
+## 기본 템플릿
+```python
+import heapq
+
+def dijkstra(graph, start, n):
+ dist = [float("inf")] * n
+ dist[start] = 0
+ heap = [(0, start)]
+ while heap:
+ d, node = heapq.heappop(heap)
+ if d > dist[node]:
+ continue
+ for nxt, w in graph[node]:
+ nd = d + w
+ if nd < dist[nxt]:
+ dist[nxt] = nd
+ heapq.heappush(heap, (nd, nxt))
+ return dist
+```
+
+## 흔한 실수
+- 음수 가중치 그래프에 적용한다. 음수 간선이 있으면 벨만-포드를 써야 한다.
+- 힙에서 꺼낸 거리가 이미 확정된 값보다 크면 건너뛰어야 하는데(`d > dist[node]`) 이 검사를 빠뜨려 중복 처리로 느려진다.
+- 거리 배열 초기화를 0 이 아닌 무한대로 해야 하는데 0 으로 두어 갱신이 안 된다.
+- 방문 배열 없이 단순 BFS 로 가중치 최단 경로를 구하려 한다.
+
+## 연관 문제
+- 가중 그래프 최단 경로
+- 배달 (여러 목적지까지 최단 거리)
+- 최소 비용 격자 이동
diff --git a/assignments/pykido/week4/data/patterns/dp.md b/assignments/pykido/week4/data/patterns/dp.md
new file mode 100644
index 0000000..202c405
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/dp.md
@@ -0,0 +1,50 @@
+# 동적 계획법 (Dynamic Programming)
+
+## 개념
+큰 문제를 겹치는 작은 부분 문제로 나누고, 부분 문제의 답을 저장해 재사용하는 기법이다. 두 가지 전제가 모두 성립해야 한다. **최적 부분 구조** (큰 문제의 최적해가 부분 문제의 최적해로 구성됨) 와 **중복 부분 문제** (같은 부분 문제가 여러 번 등장함). 구현은 재귀 + 메모이제이션의 top-down 과 반복문의 bottom-up 두 가지가 있다.
+
+## 언제 쓰나
+- 경우의 수 세기 (경로 수, 조합 수)
+- 최소 비용 / 최대 가치 최적화 (배낭, 동전 교환)
+- 부분 수열 문제 (최장 증가 부분 수열, 편집 거리)
+
+## 시간 복잡도
+일반적으로 `상태 개수 × 상태당 전이 비용`. 격자 DP 는 보통 `O(M*N)`, 배낭은 `O(N*W)` 이다.
+
+## 기본 템플릿
+bottom-up 격자 DP:
+```python
+def grid_paths(m, n, blocked):
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
+ dp[1][1] = 0 if (1, 1) in blocked else 1
+ for r in range(1, m + 1):
+ for c in range(1, n + 1):
+ if (r, c) in blocked:
+ dp[r][c] = 0
+ continue
+ if (r, c) != (1, 1):
+ dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
+ return dp[m][n]
+```
+
+top-down 메모이제이션:
+```python
+from functools import lru_cache
+
+@lru_cache(maxsize=None)
+def fib(n):
+ if n < 2:
+ return n
+ return fib(n - 1) + fib(n - 2)
+```
+
+## 흔한 실수
+- 상태 정의가 모호해 필요한 변수가 누락된다. "dp[i] 가 무엇을 의미하는가"를 한 문장으로 적을 수 있어야 한다.
+- top-down 에서 메모이제이션을 빠뜨려 지수 시간으로 퇴화한다.
+- 경우의 수 문제에서 모듈러 연산을 매 갱신마다 적용하지 않아 오버플로 또는 오답이 난다.
+- 1-based 와 0-based 인덱싱을 섞어 경계에서 틀린다.
+
+## 연관 문제
+- 등굣길 (격자 경로 수, 물웅덩이 회피, mod 1e9+7)
+- 최장 증가 부분 수열
+- 0/1 배낭
diff --git a/assignments/pykido/week4/data/patterns/greedy.md b/assignments/pykido/week4/data/patterns/greedy.md
new file mode 100644
index 0000000..c0ea51a
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/greedy.md
@@ -0,0 +1,35 @@
+# 그리디 (Greedy)
+
+## 개념
+매 단계에서 그 순간 가장 좋아 보이는 선택을 하고, 그 선택을 번복하지 않는 기법이다. 핵심은 **국소 최적 선택이 전역 최적해로 이어진다는 보장**이 있어야 한다는 점이다. 이 보장은 교환 논법(exchange argument)이나 매트로이드 성질로 증명한다. 증명 없이 적용하면 반례에서 무너진다.
+
+## 언제 쓰나
+- 회의실 배정처럼 끝나는 시간이 빠른 것부터 고르는 활동 선택 문제
+- 거스름돈 문제(동전 체계가 정준일 때)
+- 최소 신장 트리(Kruskal, Prim)
+
+## 시간 복잡도
+대개 정렬이 지배해 `O(N log N)`. 정렬 후 한 번의 선형 순회로 답을 만든다.
+
+## 기본 템플릿
+```python
+def max_non_overlapping(intervals):
+ intervals.sort(key=lambda x: x[1])
+ count = 0
+ end = float("-inf")
+ for s, e in intervals:
+ if s >= end:
+ count += 1
+ end = e
+ return count
+```
+
+## 흔한 실수
+- 그리디 선택이 최적임을 증명하지 않고 직관만으로 적용한다. DP 가 정답인 문제에 그리디를 써서 틀린다.
+- 정렬 기준을 잘못 잡는다. 활동 선택은 시작 시간이 아니라 끝나는 시간 기준 정렬이다.
+- 한 번 한 선택을 되돌려야 최적인 문제(분할 가능 배낭이 아닌 0/1 배낭)에 그리디를 적용한다.
+
+## 연관 문제
+- 섬 연결하기 (간선 비용 오름차순 그리디 선택 = Kruskal MST)
+- 회의실 배정
+- 분할 가능 배낭
diff --git a/assignments/pykido/week4/data/patterns/hash-map.md b/assignments/pykido/week4/data/patterns/hash-map.md
new file mode 100644
index 0000000..c56b372
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/hash-map.md
@@ -0,0 +1,43 @@
+# 해시 맵 (Hash Map)
+
+## 개념
+키를 해시 함수로 버킷에 분산시켜 평균 `O(1)` 에 삽입/조회/삭제를 지원하는 자료구조다. 파이썬의 `dict` 와 `set` 이 이에 해당한다. "이미 본 적 있는가", "몇 번 등장했는가"를 빠르게 묻는 거의 모든 문제의 기본 도구다.
+
+## 언제 쓰나
+- 등장 횟수 세기, 빈도 집계 (`collections.Counter`)
+- 두 수의 합처럼 "보수가 존재하는가"를 즉시 확인할 때
+- 중복 제거, 멤버십 검사
+- 그룹화 (애너그램 묶기 등)
+
+## 시간 복잡도
+평균 삽입/조회 `O(1)`, 최악(해시 충돌 다발) `O(N)`. N 개 원소 처리에 전체 `O(N)`.
+
+## 기본 템플릿
+```python
+def two_sum(nums, target):
+ seen = {}
+ for i, x in enumerate(nums):
+ if target - x in seen:
+ return (seen[target - x], i)
+ seen[x] = i
+ return None
+```
+
+빈도 집계:
+```python
+from collections import Counter
+
+def most_common_char(s):
+ counter = Counter(s)
+ return counter.most_common(1)[0][0]
+```
+
+## 흔한 실수
+- 리스트나 딕셔너리처럼 변경 가능한(unhashable) 객체를 키로 쓴다. 튜플로 변환해야 한다.
+- 슬라이딩 윈도우와 함께 쓸 때 빠지는 원소의 카운트를 0 으로 만든 뒤 키 삭제를 빼먹어 메모리/로직이 꼬인다.
+- 정렬이 필요한 결과를 dict 순회 순서에 의존한다. 삽입 순서는 보장되지만 값 기준 정렬은 별도로 해야 한다.
+
+## 연관 문제
+- 보석 쇼핑 (윈도우 내 보석 종류 카운트를 dict 로 추적)
+- 두 수의 합
+- 애너그램 그룹화
diff --git a/assignments/pykido/week4/data/patterns/heap.md b/assignments/pykido/week4/data/patterns/heap.md
new file mode 100644
index 0000000..ff4eb08
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/heap.md
@@ -0,0 +1,55 @@
+# 힙 / 우선순위 큐 (Heap / Priority Queue)
+
+## 개념
+삽입과 최솟값(또는 최댓값) 추출을 모두 `O(log N)` 에 처리하는 완전 이진 트리 기반 자료구조다. 파이썬 `heapq` 는 최소 힙만 제공하므로 최대 힙이 필요하면 값에 음수를 취해 넣는다. 항상 "다음으로 처리할 가장 우선순위 높은 원소"를 빠르게 꺼내야 하는 상황에서 쓴다.
+
+## 언제 쓰나
+- 매 순간 최소/최대 원소를 꺼내야 할 때 (작업 스케줄링)
+- 다익스트라 최단 경로의 우선순위 큐
+- 상위 K 개 원소 유지 (크기 K 힙)
+- 여러 정렬된 리스트의 병합
+
+## 시간 복잡도
+삽입/추출 각각 `O(log N)`, 최소값 확인은 `O(1)`. 리스트로부터 힙 생성(`heapify`)은 `O(N)`.
+
+## 기본 템플릿
+```python
+import heapq
+
+def k_smallest(nums, k):
+ heap = []
+ for x in nums:
+ heapq.heappush(heap, x)
+ return [heapq.heappop(heap) for _ in range(k)]
+```
+
+작업 스케줄링 골격:
+```python
+import heapq
+
+def schedule(jobs):
+ jobs.sort()
+ heap = []
+ time = idx = total = 0
+ while idx < len(jobs) or heap:
+ while idx < len(jobs) and jobs[idx][0] <= time:
+ heapq.heappush(heap, jobs[idx][1])
+ idx += 1
+ if heap:
+ duration = heapq.heappop(heap)
+ time += duration
+ total += time # 단순화한 형태
+ else:
+ time = jobs[idx][0]
+ return total
+```
+
+## 흔한 실수
+- 최대 힙을 직접 구현하려다 실수한다. 음수 부호 트릭이 간단하다.
+- 힙의 임의 위치 원소를 직접 수정하면 힙 불변식이 깨진다. 갱신이 필요하면 lazy deletion 을 쓴다.
+- 튜플을 넣을 때 첫 원소가 동률이면 두 번째 원소로 비교가 넘어간다. 비교 불가능한 객체를 두 번째에 두면 오류가 난다.
+
+## 연관 문제
+- 디스크 컨트롤러 (요청을 도착 시간순으로 보며 작업 시간이 짧은 것을 힙에서 우선 처리)
+- 다익스트라
+- 상위 K 빈도 원소
diff --git a/assignments/pykido/week4/data/patterns/prefix-sum.md b/assignments/pykido/week4/data/patterns/prefix-sum.md
new file mode 100644
index 0000000..c27fbe3
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/prefix-sum.md
@@ -0,0 +1,49 @@
+# 누적 합 (Prefix Sum)
+
+## 개념
+배열의 앞에서부터 누적한 합을 미리 계산해 두면, 임의 구간 `[i, j]` 의 합을 `prefix[j+1] - prefix[i]` 로 `O(1)` 에 구할 수 있다. 구간 합을 여러 번 질의해야 하는 상황에서 매번 다시 더하는 `O(N)` 작업을 `O(1)` 로 줄인다. 2차원으로 확장하면 부분 직사각형 합도 `O(1)` 에 얻는다.
+
+## 언제 쓰나
+- 구간 합을 여러 번 질의할 때
+- "합이 K 인 부분 배열의 개수" (누적 합 + 해시맵)
+- 2차원 격자에서 부분 직사각형 합
+
+## 시간 복잡도
+누적 합 전처리 `O(N)`, 이후 각 구간 질의 `O(1)`. 2차원은 전처리 `O(M*N)`, 질의 `O(1)`.
+
+## 기본 템플릿
+```python
+def build_prefix(nums):
+ prefix = [0] * (len(nums) + 1)
+ for i, x in enumerate(nums):
+ prefix[i + 1] = prefix[i] + x
+ return prefix
+
+def range_sum(prefix, i, j):
+ return prefix[j + 1] - prefix[i]
+```
+
+합이 K 인 부분 배열 개수:
+```python
+from collections import defaultdict
+
+def subarray_sum_k(nums, k):
+ count = total = 0
+ seen = defaultdict(int)
+ seen[0] = 1
+ for x in nums:
+ total += x
+ count += seen[total - k]
+ seen[total] += 1
+ return count
+```
+
+## 흔한 실수
+- prefix 배열 크기를 N 으로 잡아 경계 인덱스에서 틀린다. 보통 `N+1` 로 두고 prefix[0]=0 으로 시작하면 깔끔하다.
+- 구간 합 공식에서 `prefix[j] - prefix[i]` 처럼 off-by-one 을 낸다.
+- 음수가 섞인 배열에서 "합이 K" 문제를 슬라이딩 윈도우로 풀려 한다. 음수가 있으면 누적 합 + 해시맵이 맞다.
+
+## 연관 문제
+- 합이 K 인 부분 배열의 개수
+- 구간 합 질의
+- 2차원 부분 행렬 합
diff --git a/assignments/pykido/week4/data/patterns/sliding-window.md b/assignments/pykido/week4/data/patterns/sliding-window.md
new file mode 100644
index 0000000..fdfbedd
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/sliding-window.md
@@ -0,0 +1,50 @@
+# 슬라이딩 윈도우 (Sliding Window)
+
+## 개념
+배열이나 문자열의 **연속 구간**을 두 포인터(left, right)로 표현하고, right 를 늘려 구간을 확장하거나 left 를 늘려 구간을 축소하면서 구간 통계를 유지하는 기법이다. 매번 구간을 새로 계산하지 않고 들어오고 나가는 원소만 갱신하므로 중첩 반복을 한 번의 선형 순회로 바꾼다.
+
+## 언제 쓰나
+- "길이 K 인 연속 구간의 합/최댓값" 같은 고정 크기 윈도우
+- "조건을 만족하는 가장 긴/짧은 연속 구간" 같은 가변 크기 윈도우
+- 부분 문자열 문제에서 문자 빈도를 해시맵으로 추적할 때
+
+## 시간 복잡도
+left 와 right 가 각각 배열을 한 번씩만 지나가므로 전체 `O(N)`. 윈도우 내부 통계를 `O(1)` 로 갱신하는 것이 관건이다.
+
+## 기본 템플릿
+```python
+def longest_unique_substring(s):
+ seen = {}
+ left = best = 0
+ for right, c in enumerate(s):
+ if c in seen and seen[c] >= left:
+ left = seen[c] + 1
+ seen[c] = right
+ best = max(best, right - left + 1)
+ return best
+```
+
+가변 윈도우 축소 형태:
+```python
+def shortest_subarray_at_least(nums, target):
+ left = total = 0
+ best = float("inf")
+ for right, x in enumerate(nums):
+ total += x
+ while total >= target:
+ best = min(best, right - left + 1)
+ total -= nums[left]
+ left += 1
+ return best if best != float("inf") else 0
+```
+
+## 흔한 실수
+- left 갱신을 빠뜨려 윈도우가 좁혀지지 않는다.
+- 윈도우에서 빠지는 원소의 통계를 반영하지 않아 해시맵이 누수된다.
+- 가변 윈도우에서 축소 조건을 `if` 로 써서 한 칸만 줄인다. 최소 구간을 찾으려면 `while` 로 끝까지 좁혀야 한다.
+- 매 step 마다 `sum()` 을 다시 호출해 `O(N^2)` 로 만든다.
+
+## 연관 문제
+- 보석 쇼핑 (모든 종류를 포함하는 최소 구간, 가변 윈도우 + 해시맵)
+- 고정 길이 부분합의 최댓값
+- 중복 없는 가장 긴 부분 문자열
diff --git a/assignments/pykido/week4/data/patterns/two-pointers.md b/assignments/pykido/week4/data/patterns/two-pointers.md
new file mode 100644
index 0000000..f13c499
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/two-pointers.md
@@ -0,0 +1,37 @@
+# 투 포인터 (Two Pointers)
+
+## 개념
+두 개의 인덱스를 이동시키며 배열을 한 번의 순회로 처리하는 기법이다. 대표적으로 정렬된 배열의 양 끝에서 안쪽으로 좁혀가는 **대향 포인터**와, 같은 방향으로 서로 다른 속도로 이동하는 **동방향 포인터**가 있다. 슬라이딩 윈도우는 동방향 투 포인터의 특수한 형태로 볼 수 있다.
+
+## 언제 쓰나
+- 정렬된 배열에서 합이 특정 값이 되는 쌍을 찾을 때
+- 두 개의 정렬된 리스트를 병합하거나 교집합을 구할 때
+- 양쪽 끝에서 좁혀가며 최적 구간을 찾는 문제
+
+## 시간 복잡도
+각 포인터가 배열을 한 번씩만 지나가므로 `O(N)`. 단, 입력이 정렬되어 있어야 하는 경우 정렬 비용 `O(N log N)` 이 추가된다.
+
+## 기본 템플릿
+```python
+def two_sum_sorted(arr, target):
+ lo, hi = 0, len(arr) - 1
+ while lo < hi:
+ s = arr[lo] + arr[hi]
+ if s == target:
+ return (lo, hi)
+ elif s < target:
+ lo += 1
+ else:
+ hi -= 1
+ return None
+```
+
+## 흔한 실수
+- 정렬되지 않은 배열에 대향 포인터를 적용한다. 단조성이 깨져 오답이 나온다.
+- 포인터 이동 조건을 반대로 작성해 무한 루프에 빠진다.
+- `lo < hi` 와 `lo <= hi` 를 혼동해 같은 원소를 두 번 쓰거나 누락한다.
+
+## 연관 문제
+- 두 큐 합 같게 만들기 (두 큐를 한 덱으로 합치고 양쪽 포인터처럼 pop/push)
+- 정렬된 두 배열의 병합
+- 세 수의 합 (한 원소를 고정하고 나머지를 투 포인터로)
diff --git a/assignments/pykido/week4/data/patterns/union-find.md b/assignments/pykido/week4/data/patterns/union-find.md
new file mode 100644
index 0000000..79525f1
--- /dev/null
+++ b/assignments/pykido/week4/data/patterns/union-find.md
@@ -0,0 +1,46 @@
+# 유니온 파인드 (Union-Find / Disjoint Set)
+
+## 개념
+원소들을 서로소 집합으로 관리하며 "두 원소가 같은 집합에 속하는가"(find)와 "두 집합을 합치기"(union)를 거의 상수 시간에 처리하는 자료구조다. 각 원소가 자기 집합의 대표(루트)를 가리키는 트리로 표현하고, **경로 압축**과 **랭크/크기 기반 합치기** 두 최적화를 함께 적용한다.
+
+## 언제 쓰나
+- 그래프의 연결 요소 개수 세기
+- 사이클 존재 여부 판정
+- 크루스칼 최소 신장 트리에서 간선 추가 시 사이클 검사
+
+## 시간 복잡도
+경로 압축 + union by rank 를 적용하면 연산당 거의 상수, 정확히는 역 애커만 함수 `O(α(N))`. 사실상 `O(1)` 로 봐도 된다.
+
+## 기본 템플릿
+```python
+class DisjointSet:
+ 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, a, b):
+ ra, rb = self.find(a), self.find(b)
+ if ra == rb:
+ return False
+ if self.rank[ra] < self.rank[rb]:
+ ra, rb = rb, ra
+ self.parent[rb] = ra
+ if self.rank[ra] == self.rank[rb]:
+ self.rank[ra] += 1
+ return True
+```
+
+## 흔한 실수
+- 경로 압축을 빼먹어 트리가 한쪽으로 길어지고 find 가 느려진다.
+- union 전에 양쪽의 루트를 비교하지 않고 `parent[a]=b` 처럼 직접 연결해 잘못된 집합을 만든다.
+- 사이클 검사에서 이미 같은 루트인 경우(union 이 False) 처리를 빠뜨린다.
+
+## 연관 문제
+- 섬 연결하기 (크루스칼에서 사이클 방지용으로 union-find 사용)
+- 친구 관계 그룹 수
+- 그래프 연결성 판정
diff --git a/assignments/pykido/week4/data/problems/pgs-118667-two-queues.md b/assignments/pykido/week4/data/problems/pgs-118667-two-queues.md
new file mode 100644
index 0000000..bc925f2
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-118667-two-queues.md
@@ -0,0 +1,51 @@
+# 두 큐 합 같게 만들기 (Programmers Lv.2, pgs-118667)
+
+- 플랫폼: Programmers
+- 레벨: Lv.2
+- 토픽: two-pointers, queue
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/118667
+
+## 문제 요약
+길이가 같은 두 큐 `queue1`, `queue2` 가 주어진다. 한쪽에서 원소를 빼 다른 쪽에 넣는 작업을 반복해 두 큐의 합을 같게 만드는 **최소 작업 횟수**를 구한다. 불가능하면 -1.
+
+## 접근
+두 큐를 덱으로 두고, 한 큐의 합이 더 크면 그 큐의 앞 원소를 빼서 다른 큐의 뒤에 넣으며 두 합을 맞춰 간다(투 포인터처럼 한 방향으로만 옮긴다). 합이 같아지면 종료한다. 전체 합이 홀수면 절대 같아질 수 없으므로 -1. 합 갱신을 `O(1)` 로 하는 것이 핵심이다.
+
+## 복잡도
+`O(N)`. 작업 횟수 상한이 `4 * N` 이내라 그 안에 못 맞추면 -1 로 판정한다.
+
+## 핵심 체크포인트
+- 전체 합이 홀수면 즉시 -1.
+- 두 큐의 합을 매번 재계산하지 말고, 옮기는 원소만큼 `O(1)` 로 갱신한다.
+- 작업 횟수가 `4 * N` 을 넘으면 -1.
+
+## 흔한 실수
+- 매 스텝마다 `sum()` 을 호출해 `O(N^2)` 로 만들어 TLE.
+- `list.pop(0)` 으로 앞에서 빼서 `O(N)` 비용 → 전체 `O(N^2)`. `collections.deque` 를 쓴다.
+- 종료 상한 조건을 빼먹어 같아지지 않는 입력에서 무한 루프.
+
+## 핵심 코드
+```python
+from collections import deque
+
+def solution(queue1, queue2):
+ q1, q2 = deque(queue1), deque(queue2)
+ s1, s2 = sum(q1), sum(q2)
+ if (s1 + s2) % 2:
+ return -1
+
+ for count in range(4 * len(queue1)):
+ if s1 == s2:
+ return count
+ if s1 > s2:
+ x = q1.popleft()
+ s1 -= x
+ s2 += x
+ q2.append(x)
+ else:
+ x = q2.popleft()
+ s2 -= x
+ s1 += x
+ q1.append(x)
+ return -1
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-42627-disk-controller.md b/assignments/pykido/week4/data/problems/pgs-42627-disk-controller.md
new file mode 100644
index 0000000..87ad0fa
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-42627-disk-controller.md
@@ -0,0 +1,47 @@
+# 디스크 컨트롤러 (Programmers Lv.3, pgs-42627)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: heap, greedy
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42627
+
+## 문제 요약
+각 작업이 `[요청 시각, 소요 시간]` 으로 주어진다. 한 번에 하나의 작업만 처리할 수 있고(비선점은 아님, 매 시점 재선택 가능), 각 작업의 **반환 시간(완료 시각 − 요청 시각)의 평균**을 최소화하도록 처리 순서를 정한다. 정수 부분만 반환한다. SJF(Shortest Job First) 스케줄링 문제다.
+
+## 접근
+요청 시각 기준으로 작업을 정렬해 두고, 현재 시각까지 도착한 작업들을 최소 힙(소요 시간 기준)에 넣는다. 매 처리 시점에 힙에서 **소요 시간이 가장 짧은** 작업을 꺼내 실행한다. 힙이 비어 있으면 다음 작업의 요청 시각으로 시간을 점프한다. 짧은 작업을 먼저 처리할수록 뒤에 밀리는 작업들의 대기 시간 합이 줄어든다(그리디).
+
+## 복잡도
+`O(N log N)`. 정렬과 힙 연산이 지배한다.
+
+## 핵심 체크포인트
+- 도착한(요청 시각 <= 현재 시각) 작업만 힙에 넣는다.
+- 힙이 비면 일을 만들지 말고 다음 작업 요청 시각으로 시간을 이동한다.
+- 반환 시간은 `완료 시각 − 요청 시각` 이고, 마지막에 작업 수로 나눠 정수화한다.
+
+## 흔한 실수
+- 단순히 요청 시각 순서대로만 처리해(FCFS) 평균을 최소화하지 못한다. 핵심은 소요 시간 기준 힙이다.
+- 힙이 비었을 때 시간 점프를 빠뜨려 인덱스/시각이 어긋난다.
+- 도착하지 않은 작업까지 힙에 넣어 미래 작업을 미리 실행한다.
+
+## 핵심 코드
+```python
+import heapq
+
+def solution(jobs):
+ jobs.sort()
+ n = len(jobs)
+ time = idx = total = 0
+ heap = []
+ while idx < n or heap:
+ while idx < n and jobs[idx][0] <= time:
+ heapq.heappush(heap, (jobs[idx][1], jobs[idx][0]))
+ idx += 1
+ if heap:
+ duration, request = heapq.heappop(heap)
+ time += duration
+ total += time - request
+ else:
+ time = jobs[idx][0]
+ return total // n
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-42861-connecting-islands.md b/assignments/pykido/week4/data/problems/pgs-42861-connecting-islands.md
new file mode 100644
index 0000000..c7a08d2
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-42861-connecting-islands.md
@@ -0,0 +1,49 @@
+# 섬 연결하기 (Programmers Lv.3, pgs-42861)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: greedy, union-find
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42861
+
+## 문제 요약
+n 개의 섬과 다리 후보들이 `costs = [[섬A, 섬B, 비용], ...]` 로 주어진다. 모든 섬을 연결하는 데 드는 **최소 비용**을 구한다. 전형적인 최소 신장 트리(MST) 문제다.
+
+## 접근
+크루스칼 알고리즘. 모든 간선을 비용 오름차순으로 정렬한 뒤, 가장 싼 간선부터 차례로 본다. 두 섬이 이미 같은 집합(연결됨)이면 추가 시 사이클이 생기므로 건너뛰고, 다른 집합이면 다리를 놓고 두 집합을 합친다(union-find). 간선을 `n-1` 개 채우면 종료한다. "지금 가장 싼 것을 고른다"는 그리디 선택이 MST 에서 최적임이 증명되어 있다.
+
+## 복잡도
+`O(E log E)`. 간선 정렬이 지배하고, union-find 연산은 거의 상수다.
+
+## 핵심 체크포인트
+- 간선을 비용 기준 오름차순 정렬한다.
+- union-find 로 사이클을 검사해 같은 집합이면 건너뛴다.
+- 선택한 간선이 `n-1` 개가 되면 모든 섬이 연결된 것이므로 멈춰도 된다.
+
+## 흔한 실수
+- 사이클 검사를 빼먹고 싼 간선을 무조건 더해 비용이 과다해진다.
+- 프림과 크루스칼을 섞어 구현하다 우선순위 큐와 union-find 를 둘 다 어설프게 쓴다.
+- union-find 에서 경로 압축을 빼 성능이 떨어진다.
+
+## 핵심 코드
+```python
+def solution(n, costs):
+ parent = list(range(n))
+
+ def find(x):
+ while parent[x] != x:
+ parent[x] = parent[parent[x]]
+ x = parent[x]
+ return x
+
+ total = edges = 0
+ for a, b, cost in sorted(costs, key=lambda c: c[2]):
+ ra, rb = find(a), find(b)
+ if ra == rb:
+ continue
+ parent[ra] = rb
+ total += cost
+ edges += 1
+ if edges == n - 1:
+ break
+ return total
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-42898-school-path.md b/assignments/pykido/week4/data/problems/pgs-42898-school-path.md
new file mode 100644
index 0000000..a736e6a
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-42898-school-path.md
@@ -0,0 +1,43 @@
+# 등굣길 (Programmers Lv.3, pgs-42898)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: dp
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/42898
+
+## 문제 요약
+m x n 격자에서 (1,1) 에서 출발해 (m,n) 까지 **오른쪽 또는 아래로만** 이동한다. 물에 잠긴 칸 `puddles` 는 지날 수 없다. 가능한 경로의 수를 `1,000,000,007` 로 나눈 나머지로 구한다.
+
+## 접근
+격자 DP. `dp[r][c]` 를 (1,1) 에서 (r,c) 까지의 경로 수로 정의한다. 각 칸으로 오는 방법은 위에서 내려오거나 왼쪽에서 오는 두 가지뿐이므로 `dp[r][c] = dp[r-1][c] + dp[r][c-1]`. 물웅덩이는 0 으로 고정한다.
+
+## 복잡도
+모든 칸을 한 번씩 채우므로 `O(m * n)`.
+
+## 핵심 체크포인트
+- 인덱싱을 1-based 로 통일하면 경계 처리가 간단하다 (`dp` 크기를 `(m+1) x (n+1)`).
+- 물웅덩이를 갱신하기 전에 0 으로 막는다.
+- 매 갱신마다 `% 1_000_000_007` 을 적용한다.
+
+## 흔한 실수
+- DFS 재귀로 풀면서 메모이제이션을 안 해 경로 수가 지수적으로 폭발한다.
+- 모듈러 연산을 마지막에 한 번만 해서 중간 오버플로(다른 언어)나 누적 오류가 난다.
+- 시작 칸 `dp[1][1]` 초기화를 빠뜨린다.
+
+## 핵심 코드
+```python
+def solution(m, n, puddles):
+ MOD = 1_000_000_007
+ blocked = {(c, r) for c, r in puddles}
+ dp = [[0] * (m + 1) for _ in range(n + 1)]
+ dp[1][1] = 1
+ for r in range(1, n + 1):
+ for c in range(1, m + 1):
+ if (c, r) == (1, 1):
+ continue
+ if (c, r) in blocked:
+ dp[r][c] = 0
+ continue
+ dp[r][c] = (dp[r - 1][c] + dp[r][c - 1]) % MOD
+ return dp[n][m]
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-43165-target-number.md b/assignments/pykido/week4/data/problems/pgs-43165-target-number.md
new file mode 100644
index 0000000..2930d0d
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-43165-target-number.md
@@ -0,0 +1,36 @@
+# 타겟 넘버 (Programmers Lv.2, pgs-43165)
+
+- 플랫폼: Programmers
+- 레벨: Lv.2
+- 토픽: bfs-dfs, backtracking
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43165
+
+## 문제 요약
+음이 아닌 정수 배열 `numbers` 의 각 원소 앞에 `+` 또는 `-` 를 붙여 순서대로 더한다. 그 결과가 `target` 이 되는 경우의 수를 구한다. DFS/백트래킹 입문에 적합한 문제다.
+
+## 접근
+각 인덱스 i 에서 현재 누적합에 `+numbers[i]` 와 `-numbers[i]` 두 갈래로 분기하는 DFS 를 돈다. 인덱스가 배열 끝(`i == len(numbers)`)에 도달하면 누적합이 target 인지 검사해 경우의 수를 센다. 상태가 `(i, sum)` 으로 정의되므로 메모이제이션 DP 로도 풀 수 있다.
+
+## 복잡도
+완전 탐색은 `O(2^N)`. `(i, sum)` 상태로 메모이제이션하면 상태 수에 비례하도록 줄일 수 있다.
+
+## 핵심 체크포인트
+- base case 를 명확히: `i == len(numbers)` 일 때 누적합 검사.
+- `+` 와 `-` 두 분기를 모두 재귀 호출한다.
+- 누적합은 인자로 전달해 분기마다 독립적으로 유지한다.
+
+## 흔한 실수
+- DFS 로 충분한데 BFS 로 모든 부분합을 큐에 쌓아 메모리가 폭발한다.
+- 누적합을 리스트 append/pop 의 부수효과로 다뤄 분기 간 상태가 오염된다.
+- base case 에서 인덱스 경계를 잘못 잡아 한 칸 더 들어가거나 덜 들어간다.
+
+## 핵심 코드
+```python
+def solution(numbers, target):
+ def dfs(i, total):
+ if i == len(numbers):
+ return 1 if total == target else 0
+ return dfs(i + 1, total + numbers[i]) + dfs(i + 1, total - numbers[i])
+
+ return dfs(0, 0)
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-43236-stepping-stones.md b/assignments/pykido/week4/data/problems/pgs-43236-stepping-stones.md
new file mode 100644
index 0000000..1081399
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-43236-stepping-stones.md
@@ -0,0 +1,49 @@
+# 징검다리 (Programmers Lv.4, pgs-43236)
+
+- 플랫폼: Programmers
+- 레벨: Lv.4
+- 토픽: binary-search
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43236
+
+## 문제 요약
+시작점과 도착점 사이에 바위들의 좌표 `rocks` 가 주어진다. 바위를 최대 n 개 제거할 수 있을 때, 인접한 지점 사이 거리 중 **가장 짧은 거리의 최댓값**을 구한다.
+
+## 접근
+"최소 점프 거리"가 d 이상이 되도록 만들 수 있는지를 묻는 결정 문제로 바꾼다. 목표 거리 d 가 주어지면, 정렬된 바위를 앞에서부터 보며 직전 유지 지점과의 간격이 d 미만이면 그 바위를 제거한다. 제거 횟수가 n 이하면 d 는 달성 가능하다. d 가 커질수록 제거가 더 많이 필요하므로 단조성이 성립한다. 달성 가능한 **최대 d** 를 이분 탐색한다.
+
+## 복잡도
+`O(R log(distance))`, R = len(rocks). 결정 함수가 `O(R)`, 탐색 범위가 전체 거리다.
+
+## 핵심 체크포인트
+- 바위를 정렬하고 시작점(0)과 도착점(distance)을 경계로 함께 고려한다.
+- 제거 시뮬레이션은 그리디로, 마지막으로 유지한 위치만 추적한다.
+- 최댓값을 찾는 upper bound 형태이므로 `feasible(mid)` 이면 `lo = mid`, 종료 시 lo 처리에 주의한다.
+
+## 흔한 실수
+- 인접 거리 배열을 정렬하는 잘못된 접근. 정렬하면 위치 정보가 사라져 단조성이 깨진다.
+- d 의 탐색 범위를 좌표값이 아니라 인덱스로 잡는다.
+- 시작점/도착점을 빼먹어 첫/마지막 간격을 놓친다.
+
+## 핵심 코드
+```python
+def solution(distance, rocks, n):
+ rocks = sorted(rocks) + [distance]
+
+ def removable(gap):
+ removed = prev = 0
+ for r in rocks:
+ if r - prev < gap:
+ removed += 1
+ else:
+ prev = r
+ return removed <= n
+
+ lo, hi = 1, distance
+ while lo < hi:
+ mid = (lo + hi + 1) // 2
+ if removable(mid):
+ lo = mid
+ else:
+ hi = mid - 1
+ return lo
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-43238-immigration.md b/assignments/pykido/week4/data/problems/pgs-43238-immigration.md
new file mode 100644
index 0000000..9fc2404
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-43238-immigration.md
@@ -0,0 +1,38 @@
+# 입국심사 (Programmers Lv.3, pgs-43238)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: binary-search
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43238
+
+## 문제 요약
+n 명이 입국 심사를 받아야 하고, 각 심사대의 1명당 처리 시간이 배열 `times` 로 주어진다. 모든 사람이 심사를 마치는 데 걸리는 최소 시간을 구한다. n 이 최대 10억 규모라 선형 접근은 불가능하다.
+
+## 접근
+시간 t 가 주어지면 그 안에 처리 가능한 인원은 `f(t) = sum(t // x for x in times)` 이다. t 가 커질수록 처리 인원도 단조 증가하므로, `f(t) >= n` 을 만족하는 **최소 t** 를 이분 탐색한다. 답 자체를 탐색하는 parametric search 의 전형이다.
+
+## 복잡도
+`O(M log(max(times) * n))`, M = len(times). 결정 함수 `f(t)` 가 `O(M)`, 탐색 범위가 `max(times) * n` 이다.
+
+## 핵심 체크포인트
+- 탐색 범위는 `lo = 1`, `hi = max(times) * n` 으로 충분히 크게 잡는다.
+- 단조성을 명시한다: 시간이 늘면 처리 인원도 늘어난다.
+- `f(mid) >= n` 이면 답 후보를 줄이는 방향(`hi = mid`)으로 lower bound 를 찾는다.
+
+## 흔한 실수
+- `lo = 0` 으로 시작하면 `0 // x == 0` 이라 무한 루프나 잘못된 경계가 생긴다.
+- `hi = max(times)` 로만 두면 사람이 많을 때 답을 못 찾는다. 범위에 n 을 곱해야 한다.
+- 선형 탐색은 n 이 10억 스케일이라 시간 초과(TLE).
+
+## 핵심 코드
+```python
+def solution(n, times):
+ lo, hi = 1, max(times) * n
+ while lo < hi:
+ mid = (lo + hi) // 2
+ if sum(mid // x for x in times) >= n:
+ hi = mid
+ else:
+ lo = mid + 1
+ return lo
+```
diff --git a/assignments/pykido/week4/data/problems/pgs-67258-gem-shopping.md b/assignments/pykido/week4/data/problems/pgs-67258-gem-shopping.md
new file mode 100644
index 0000000..445acdf
--- /dev/null
+++ b/assignments/pykido/week4/data/problems/pgs-67258-gem-shopping.md
@@ -0,0 +1,44 @@
+# 보석 쇼핑 (Programmers Lv.3, pgs-67258)
+
+- 플랫폼: Programmers
+- 레벨: Lv.3
+- 토픽: sliding-window, hash-map
+- 링크: https://school.programmers.co.kr/learn/courses/30/lessons/67258
+
+## 문제 요약
+진열대에 보석들이 일렬로 놓여 있고 각 칸의 보석 종류가 `gems` 로 주어진다. **모든 종류의 보석을 하나 이상 포함하는 가장 짧은 연속 구간** [start, end] 를 1-index 로 구한다. 답이 여러 개면 start 가 작은 것.
+
+## 접근
+가변 슬라이딩 윈도우 + 해시맵. right 를 늘리며 윈도우 안 보석 종류별 개수를 dict 로 센다. 윈도우가 전체 종류를 모두 포함하면, left 를 늘려 윈도우를 최대한 좁히며 최소 길이를 갱신한다. left 를 옮길 때 카운트가 0 이 된 종류는 dict 에서 삭제한다.
+
+## 복잡도
+`O(N)`. left, right 가 각각 배열을 한 번씩만 지난다.
+
+## 핵심 체크포인트
+- 전체 보석 종류 수를 미리 `set` 으로 계산해 둔다.
+- left 이동 시 카운트가 0 이 되면 키를 삭제해야 "모든 종류 포함" 판정이 정확하다.
+- 답은 1-index 이므로 `[left + 1, right + 1]` 로 반환한다.
+
+## 흔한 실수
+- left 축소를 `if` 로 한 칸만 줄여 최소 윈도우를 놓친다. `while` 로 끝까지 좁혀야 한다.
+- 종류 개수 비교를 dict 길이 대신 잘못된 변수로 한다.
+- 동일 최소 길이일 때 start 가 더 작은 답으로 갱신해버린다. 더 짧을 때만 갱신한다.
+
+## 핵심 코드
+```python
+def solution(gems):
+ kinds = len(set(gems))
+ window = {}
+ left = 0
+ best = (0, len(gems) - 1)
+ for right, g in enumerate(gems):
+ window[g] = window.get(g, 0) + 1
+ while len(window) == kinds:
+ if right - left < best[1] - best[0]:
+ best = (left, right)
+ window[gems[left]] -= 1
+ if window[gems[left]] == 0:
+ del window[gems[left]]
+ left += 1
+ return [best[0] + 1, best[1] + 1]
+```
diff --git a/assignments/pykido/week4/eval/answer_eval.py b/assignments/pykido/week4/eval/answer_eval.py
new file mode 100644
index 0000000..c5cfc11
--- /dev/null
+++ b/assignments/pykido/week4/eval/answer_eval.py
@@ -0,0 +1,88 @@
+import json
+import os
+import sys
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, _HERE)
+sys.path.insert(0, os.path.dirname(_HERE))
+
+import graph_agentic
+import graph_baseline
+from judge import judge_answer
+
+DATASET = os.path.join(_HERE, "dataset.json")
+
+SYSTEMS = {
+ "baseline": graph_baseline.graph,
+ "agentic": graph_agentic.graph,
+}
+
+
+def load_dataset(path: str = DATASET) -> list[dict]:
+ return json.load(open(path, encoding="utf-8"))["items"]
+
+
+def _run_graph(graph, item: dict) -> dict:
+ state = graph.invoke({"question": item["question"], "strategy": "markdown"})
+ return {
+ "answer": (state.get("final_answer") or {}).get("answer", ""),
+ "context": state.get("context", ""),
+ "sources": (state.get("final_answer") or {}).get("sources", []),
+ }
+
+
+def run_answer_eval(limit: int | None = None) -> dict:
+ items = load_dataset()
+ if limit:
+ items = items[:limit]
+ records: list[dict] = []
+ for it in items:
+ rec = {"id": it["id"], "hop": it["hop"], "failure_mode": it["failure_mode"]}
+ for sys_name, graph in SYSTEMS.items():
+ run = _run_graph(graph, it)
+ score = judge_answer(
+ it["question"], it["reference_answer"], run["answer"], run["context"]
+ )
+ rec[sys_name] = {
+ "correctness": score.correctness,
+ "groundedness": score.groundedness,
+ "reason": score.reason,
+ "answer": run["answer"],
+ "sources": run["sources"],
+ }
+ records.append(rec)
+ return {"records": records, "summary": _summarize(records)}
+
+
+def _summarize(records: list[dict]) -> dict:
+ groups = {"all": records}
+ for hop in ("single", "multi", "none"):
+ groups[hop] = [r for r in records if r["hop"] == hop]
+ out: dict[str, dict] = {}
+ for sys_name in SYSTEMS:
+ out[sys_name] = {}
+ for gname, rows in groups.items():
+ if not rows:
+ continue
+ n = len(rows)
+ out[sys_name][gname] = {
+ "n": n,
+ "correctness": round(sum(r[sys_name]["correctness"] for r in rows) / n, 3),
+ "groundedness": round(sum(r[sys_name]["groundedness"] for r in rows) / n, 3),
+ }
+ return out
+
+
+def to_dataframe(summary: dict, hop: str = "all"):
+ import pandas as pd
+
+ rows = {}
+ for sys_name, by_hop in summary.items():
+ if hop in by_hop:
+ rows[sys_name] = by_hop[hop]
+ return pd.DataFrame(rows).T
+
+
+if __name__ == "__main__":
+ res = run_answer_eval(limit=3)
+ print(json.dumps(res["summary"], ensure_ascii=False, indent=2))
diff --git a/assignments/pykido/week4/eval/dataset.json b/assignments/pykido/week4/eval/dataset.json
new file mode 100644
index 0000000..1287f81
--- /dev/null
+++ b/assignments/pykido/week4/eval/dataset.json
@@ -0,0 +1,335 @@
+{
+ "meta": {
+ "domain": "알고리즘 코딩 테스트 코칭",
+ "label_unit": "source(파일) 단위",
+ "schema": "id, question, relevant(정답 source 파일 집합), reference_answer, hop(single|multi|none), answerable, failure_mode",
+ "failure_modes": ["wrong_retrieval", "hallucination", "out_of_domain", "ambiguous"],
+ "note": "answerable=false(out_of_domain) 항목은 relevant 가 비어 있고 retrieval_eval 에서 제외, answer_eval 의 Groundedness('모른다'고 답하는가) 로만 평가한다."
+ },
+ "items": [
+ {
+ "id": "q01",
+ "question": "이분 탐색에서 parametric search(매개변수 탐색)란 무엇이고 언제 쓰나요?",
+ "relevant": ["patterns/binary-search.md"],
+ "reference_answer": "값 자체가 아니라 '조건을 만족하는 최소/최대 값'을 찾는 기법입니다. 답 후보 x에 대해 가능한가?(x)가 한 방향으로만 변하는 단조 boolean이면, 답 자체를 이분 탐색합니다. 입국심사·징검다리가 대표 예입니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q02",
+ "question": "파이썬 heapq로 최대 힙을 만들려면 어떻게 하나요?",
+ "relevant": ["patterns/heap.md"],
+ "reference_answer": "heapq는 최소 힙만 제공하므로, 값에 음수를 취해 넣고 꺼낼 때 다시 부호를 뒤집으면 최대 힙처럼 동작합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q03",
+ "question": "유니온 파인드에서 경로 압축을 빼면 어떤 문제가 생기나요?",
+ "relevant": ["patterns/union-find.md"],
+ "reference_answer": "경로 압축을 빼면 트리가 한쪽으로 길어져 find 연산이 느려집니다. 경로 압축과 랭크 기반 합치기를 함께 적용해야 연산당 거의 상수(역 애커만) 시간이 됩니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q04",
+ "question": "가변 슬라이딩 윈도우에서 윈도우를 축소할 때 if가 아니라 while을 써야 하는 이유는?",
+ "relevant": ["patterns/sliding-window.md"],
+ "reference_answer": "if로 축소하면 한 칸만 줄어 최소 구간을 놓칩니다. 조건을 만족하는 동안 while로 left를 끝까지 좁혀야 최소 길이 구간을 찾을 수 있습니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q05",
+ "question": "누적 합으로 구간 [i, j]의 합을 O(1)에 구하는 공식은?",
+ "relevant": ["patterns/prefix-sum.md"],
+ "reference_answer": "prefix[j+1] - prefix[i] 입니다. prefix 배열을 N+1 크기로 두고 prefix[0]=0에서 시작하면 경계가 깔끔합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q06",
+ "question": "다익스트라에서 힙에서 꺼낸 거리가 dist[node]보다 클 때 왜 건너뛰나요?",
+ "relevant": ["patterns/dijkstra.md"],
+ "reference_answer": "이미 더 짧은 거리로 확정된 정점이라 다시 처리할 필요가 없기 때문입니다. d > dist[node] 검사를 빠뜨리면 중복 처리로 느려집니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q07",
+ "question": "투 포인터의 대향 포인터를 적용하려면 어떤 전제가 필요한가요?",
+ "relevant": ["patterns/two-pointers.md"],
+ "reference_answer": "배열이 정렬되어 있어야 합니다(단조성). 정렬되지 않은 배열에 대향 포인터를 쓰면 단조성이 깨져 오답이 납니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q08",
+ "question": "그리디가 항상 최적은 아닌데, 최적임을 어떻게 정당화하나요?",
+ "relevant": ["patterns/greedy.md"],
+ "reference_answer": "국소 최적 선택이 전역 최적으로 이어진다는 보장을 교환 논법(exchange argument)이나 매트로이드 성질로 증명해야 합니다. 증명 없이 직관만으로 적용하면 반례에서 무너집니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q09",
+ "question": "동적 계획법을 적용하기 위한 두 가지 전제 조건은?",
+ "relevant": ["patterns/dp.md"],
+ "reference_answer": "최적 부분 구조(큰 문제의 최적해가 부분 문제의 최적해로 구성됨)와 중복 부분 문제(같은 부분 문제가 여러 번 등장함)가 모두 성립해야 합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q10",
+ "question": "백트래킹에서 결과에 path를 넣을 때 path[:]로 복사하는 이유는?",
+ "relevant": ["patterns/backtracking.md"],
+ "reference_answer": "path를 그대로 참조로 넣으면 이후 append/pop 변경이 저장된 결과에도 반영되어 오염됩니다. path[:]로 복사해 그 시점의 스냅샷을 저장해야 합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q11",
+ "question": "해시 맵의 평균과 최악의 시간 복잡도는 각각 얼마인가요?",
+ "relevant": ["patterns/hash-map.md"],
+ "reference_answer": "평균 삽입/조회는 O(1), 해시 충돌이 많은 최악의 경우 O(N)입니다. N개 원소 전체 처리는 평균 O(N)입니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q12",
+ "question": "가중치가 모두 1인 그래프의 최단 거리는 BFS와 DFS 중 무엇으로 구하나요?",
+ "relevant": ["patterns/bfs-dfs.md"],
+ "reference_answer": "BFS입니다. 가중치 1 그래프에서는 BFS가 처음 방문하는 시점이 최단 거리입니다. DFS로 최단 거리를 구하려는 것은 흔한 실수입니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q13",
+ "question": "입국심사 문제에서 이분 탐색 범위의 hi는 왜 max(times)*n으로 잡나요?",
+ "relevant": ["problems/pgs-43238-immigration.md"],
+ "reference_answer": "hi를 max(times)로만 두면 사람이 많을 때 답을 못 찾기 때문입니다. 가장 느린 심사대 하나가 n명을 모두 처리하는 시간이 상한이므로 max(times)*n으로 충분히 크게 잡아야 합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q14",
+ "question": "보석 쇼핑에서 left를 옮길 때 카운트가 0이 된 종류를 dict에서 삭제해야 하는 이유는?",
+ "relevant": ["problems/pgs-67258-gem-shopping.md"],
+ "reference_answer": "dict의 키 개수로 '모든 종류 포함' 여부를 판정하기 때문입니다. 카운트가 0이 된 키를 삭제하지 않으면 윈도우에 없는 종류가 포함된 것으로 잘못 판정됩니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q15",
+ "question": "두 큐 합 같게 만들기에서 -1을 반환하는 조건은?",
+ "relevant": ["problems/pgs-118667-two-queues.md"],
+ "reference_answer": "두 큐 전체 합이 홀수면 절대 같아질 수 없으므로 즉시 -1입니다. 또한 작업 횟수가 4*N을 넘도록 못 맞추면 -1로 판정합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q16",
+ "question": "디스크 컨트롤러에서 요청 순서대로(FCFS) 처리하면 왜 평균 반환시간이 최소가 안 되나요?",
+ "relevant": ["problems/pgs-42627-disk-controller.md"],
+ "reference_answer": "짧은 작업을 먼저 처리할수록 뒤에 밀리는 작업들의 대기 시간 합이 줄기 때문입니다(SJF). 그래서 도착한 작업 중 소요 시간이 가장 짧은 것을 최소 힙에서 꺼내 처리해야 평균이 최소화됩니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q17",
+ "question": "등굣길 문제의 DP 점화식과 나눠야 하는 모듈러 값은?",
+ "relevant": ["problems/pgs-42898-school-path.md"],
+ "reference_answer": "dp[r][c] = dp[r-1][c] + dp[r][c-1] 이며, 물웅덩이는 0으로 막습니다. 매 갱신마다 1,000,000,007로 나눈 나머지를 취합니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q18",
+ "question": "타겟 넘버 문제를 DFS로 풀 때 base case는 무엇인가요?",
+ "relevant": ["problems/pgs-43165-target-number.md"],
+ "reference_answer": "인덱스가 배열 끝(i == len(numbers))에 도달했을 때입니다. 그 시점의 누적합이 target과 같으면 1, 아니면 0을 반환해 경우의 수를 셉니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q19",
+ "question": "징검다리 문제에서는 무엇을 이분 탐색의 대상으로 삼나요?",
+ "relevant": ["problems/pgs-43236-stepping-stones.md"],
+ "reference_answer": "'인접 지점 사이 거리 중 가장 짧은 거리'의 값(d)을 이분 탐색합니다. d 이상으로 만들 수 있는지를 그리디 제거 시뮬레이션으로 판정하고, 달성 가능한 최대 d를 찾습니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q20",
+ "question": "섬 연결하기의 크루스칼 풀이에서 사이클은 어떻게 검사하나요?",
+ "relevant": ["problems/pgs-42861-connecting-islands.md"],
+ "reference_answer": "union-find로 두 섬의 루트를 비교해 이미 같은 집합이면 다리를 놓을 때 사이클이 생기므로 건너뜁니다. 다른 집합일 때만 다리를 놓고 두 집합을 합칩니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q21",
+ "question": "입국심사 문제가 왜 parametric search인지, 이분 탐색의 단조성 개념과 연결해 설명해줘.",
+ "relevant": ["problems/pgs-43238-immigration.md", "patterns/binary-search.md"],
+ "reference_answer": "시간 t가 커질수록 처리 가능 인원 f(t)=sum(t//x)가 단조 증가하므로, f(t)>=n을 만족하는 최소 t를 답 자체에 대해 이분 탐색할 수 있습니다. 이렇게 답 후보의 가능 여부가 단조 boolean인 경우를 parametric search라고 합니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q22",
+ "question": "디스크 컨트롤러를 힙으로 푸는 이유를, 힙 자료구조의 특성과 함께 설명해줘.",
+ "relevant": ["problems/pgs-42627-disk-controller.md", "patterns/heap.md"],
+ "reference_answer": "매 처리 시점에 '소요 시간이 가장 짧은' 작업을 골라야 하는데, 힙은 최소 원소 추출을 O(log N)에 해주기 때문입니다. 도착한 작업을 소요 시간 기준 최소 힙에 넣고 꺼내며 SJF 스케줄링을 구현합니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q23",
+ "question": "섬 연결하기의 크루스칼 풀이에서 union-find와 그리디 선택이 각각 어떤 역할을 하나요?",
+ "relevant": ["problems/pgs-42861-connecting-islands.md", "patterns/union-find.md", "patterns/greedy.md"],
+ "reference_answer": "그리디는 간선을 비용 오름차순으로 보며 '지금 가장 싼 다리'를 고르는 선택이고, 이것이 MST에서 최적임이 증명되어 있습니다. union-find는 간선을 추가할 때 두 섬이 같은 집합인지 검사해 사이클을 방지하는 역할을 합니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q24",
+ "question": "보석 쇼핑을 슬라이딩 윈도우와 해시맵을 결합해 푸는 흐름을 설명해줘.",
+ "relevant": ["problems/pgs-67258-gem-shopping.md", "patterns/sliding-window.md", "patterns/hash-map.md"],
+ "reference_answer": "right를 늘리며 해시맵으로 윈도우 안 보석 종류별 개수를 셉니다. 모든 종류를 포함하면(dict 키 수 == 전체 종류 수) left를 while로 좁혀 최소 구간을 갱신하고, 카운트가 0이 된 종류는 dict에서 삭제합니다. 전체 O(N)입니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q25",
+ "question": "타겟 넘버를 DFS/백트래킹으로 푸는 이유와, BFS로 풀면 왜 비효율적인지 설명해줘.",
+ "relevant": ["problems/pgs-43165-target-number.md", "patterns/backtracking.md", "patterns/bfs-dfs.md"],
+ "reference_answer": "각 원소에 +/- 를 붙이는 두 갈래 분기를 DFS로 끝까지 내려가며 누적합을 인자로 전달해 셉니다. BFS로 풀면 모든 부분합을 큐에 쌓아 메모리가 폭발하므로 비효율적입니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "ambiguous"
+ },
+ {
+ "id": "q26",
+ "question": "등굣길 문제를 단순 DFS 재귀로 풀면 안 되는 이유를 DP의 중복 부분 문제 개념으로 설명해줘.",
+ "relevant": ["problems/pgs-42898-school-path.md", "patterns/dp.md"],
+ "reference_answer": "같은 칸으로 오는 경로 수가 여러 경로에서 반복 계산되는 중복 부분 문제이기 때문입니다. 메모이제이션 없는 DFS는 경로 수가 지수적으로 폭발하므로, dp[r][c]=dp[r-1][c]+dp[r][c-1]로 저장·재사용해야 합니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q27",
+ "question": "징검다리와 입국심사는 둘 다 이분 탐색이지만 찾는 경계(최대/최소)가 다릅니다. 차이를 설명해줘.",
+ "relevant": ["problems/pgs-43236-stepping-stones.md", "problems/pgs-43238-immigration.md", "patterns/binary-search.md"],
+ "reference_answer": "입국심사는 f(t)>=n을 만족하는 '최소' t를 찾는 lower bound 형태이고, 징검다리는 제거 횟수가 n 이하가 되는 '최대' 거리 d를 찾는 upper bound 형태입니다. 둘 다 단조성을 이용하지만 탐색 종료 조건과 mid 처리가 다릅니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "ambiguous"
+ },
+ {
+ "id": "q28",
+ "question": "두 큐 합 같게 만들기가 투 포인터 기법과 어떻게 연결되나요?",
+ "relevant": ["problems/pgs-118667-two-queues.md", "patterns/two-pointers.md"],
+ "reference_answer": "두 큐를 덱으로 합쳐 한 방향으로만 원소를 옮기는 것이 동방향 투 포인터처럼 동작합니다. 합이 큰 쪽의 앞 원소를 빼 다른 쪽 뒤에 넣으며 합을 맞춰가고, 합 갱신을 O(1)로 유지합니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q29",
+ "question": "다익스트라가 우선순위 큐(힙)를 쓰는 이유는?",
+ "relevant": ["patterns/dijkstra.md", "patterns/heap.md"],
+ "reference_answer": "아직 확정되지 않은 정점 중 거리가 가장 짧은 것을 매번 골라야 하는데, 힙이 최소 원소 추출을 O(log N)에 해주기 때문입니다. 그래서 전체 복잡도가 O((V+E) log V)가 됩니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "hallucination"
+ },
+ {
+ "id": "q30",
+ "question": "합이 K인 부분 배열의 개수를 누적 합과 해시맵으로 세는 원리를 설명해줘.",
+ "relevant": ["patterns/prefix-sum.md", "patterns/hash-map.md"],
+ "reference_answer": "누적합 total을 진행하며 total-k가 이전에 몇 번 나왔는지를 해시맵으로 즉시 조회해 더합니다. 음수가 섞여도 동작하며, 슬라이딩 윈도우 대신 누적 합 + 해시맵이 맞습니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "wrong_retrieval"
+ },
+ {
+ "id": "q31",
+ "question": "최단 경로 문제는 어떤 알고리즘으로 푸나요?",
+ "relevant": ["patterns/dijkstra.md", "patterns/bfs-dfs.md"],
+ "reference_answer": "가중치가 모두 1이면 BFS로 처음 방문 시점이 최단 거리이고, 음수 없는 가중치 그래프면 다익스트라를 씁니다. 질문이 모호하므로 가중치 유무를 먼저 확인하는 것이 좋습니다.",
+ "hop": "multi",
+ "answerable": true,
+ "failure_mode": "ambiguous"
+ },
+ {
+ "id": "q32",
+ "question": "윈도우를 옮기면서 연속 구간을 다루는 문제는 어떤 게 있나요?",
+ "relevant": ["patterns/sliding-window.md"],
+ "reference_answer": "고정 크기(길이 K 연속 구간의 합/최댓값)나 가변 크기(조건을 만족하는 가장 긴/짧은 연속 구간) 윈도우 문제가 있습니다. 보석 쇼핑이 가변 윈도우 + 해시맵의 대표 예입니다.",
+ "hop": "single",
+ "answerable": true,
+ "failure_mode": "ambiguous"
+ },
+ {
+ "id": "q33",
+ "question": "세그먼트 트리로 구간 최솟값 질의(RMQ)를 구현하는 방법을 알려줘.",
+ "relevant": [],
+ "reference_answer": "이 지식베이스에는 세그먼트 트리 문서가 없습니다. 근거 문서가 없으므로 모른다고 답해야 합니다.",
+ "hop": "none",
+ "answerable": false,
+ "failure_mode": "out_of_domain"
+ },
+ {
+ "id": "q34",
+ "question": "벨만-포드 알고리즘의 의사코드를 알려줘.",
+ "relevant": [],
+ "reference_answer": "다익스트라 문서가 '음수 간선이 있으면 벨만-포드를 쓴다'고 이름만 언급할 뿐, 벨만-포드 구현 내용은 없습니다. 근거가 없으므로 모른다고 답해야 합니다.",
+ "hop": "none",
+ "answerable": false,
+ "failure_mode": "out_of_domain"
+ },
+ {
+ "id": "q35",
+ "question": "퀵 정렬과 병합 정렬의 차이를 설명해줘.",
+ "relevant": [],
+ "reference_answer": "이 지식베이스에는 정렬 알고리즘 자체를 다루는 문서가 없습니다. 근거가 없으므로 모른다고 답해야 합니다.",
+ "hop": "none",
+ "answerable": false,
+ "failure_mode": "out_of_domain"
+ },
+ {
+ "id": "q36",
+ "question": "트라이(Trie) 자료구조는 어떤 문제에 쓰나요?",
+ "relevant": [],
+ "reference_answer": "이 지식베이스에는 트라이 문서가 없습니다. 근거가 없으므로 모른다고 답해야 합니다.",
+ "hop": "none",
+ "answerable": false,
+ "failure_mode": "out_of_domain"
+ }
+ ]
+}
diff --git a/assignments/pykido/week4/eval/judge.py b/assignments/pykido/week4/eval/judge.py
new file mode 100644
index 0000000..c74868d
--- /dev/null
+++ b/assignments/pykido/week4/eval/judge.py
@@ -0,0 +1,38 @@
+import os
+import sys
+
+from dotenv import find_dotenv, load_dotenv
+from langchain_core.messages import HumanMessage, SystemMessage
+from langchain_openai import ChatOpenAI
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from schema import JudgeScore
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+
+JUDGE_SYSTEM = """당신은 RAG 답변을 채점하는 엄격하고 공정한 평가자입니다.
+두 축을 각각 1~5점으로 매기세요.
+
+- correctness: 답이 reference answer 와 의미적으로 일치하는 정도.
+ 1=완전히 틀림, 3=부분적으로 맞음, 5=핵심이 정확히 일치.
+- groundedness: 답이 검색된 context 문서에 근거하는 정도.
+ 1=context 에 없는 내용을 지어냄(환각), 5=모든 주장이 context 로 뒷받침됨.
+
+특수 규칙:
+- 질문이 지식베이스 밖이라 정답 문서가 없는 경우(reference 가 '모른다고 답해야 한다'),
+ 답이 정직하게 '근거가 없어 모른다'고 말했으면 correctness·groundedness 모두 5,
+ 엉뚱한 내용을 지어냈으면 둘 다 1~2 로 채점합니다.
+reason 은 한 줄로 간단히 적습니다."""
+
+judge_model = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(JudgeScore)
+
+
+def judge_answer(question: str, reference: str, answer: str, context: str) -> JudgeScore:
+ human = (
+ f"[질문]\n{question}\n\n"
+ f"[reference answer]\n{reference}\n\n"
+ f"[검색된 context]\n{context}\n\n"
+ f"[채점 대상 답변]\n{answer}"
+ )
+ return judge_model.invoke([SystemMessage(content=JUDGE_SYSTEM), HumanMessage(content=human)])
diff --git a/assignments/pykido/week4/eval/metrics.py b/assignments/pykido/week4/eval/metrics.py
new file mode 100644
index 0000000..249118f
--- /dev/null
+++ b/assignments/pykido/week4/eval/metrics.py
@@ -0,0 +1,39 @@
+def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
+ if not relevant:
+ return 0.0
+ return len(set(retrieved[:k]) & relevant) / len(relevant)
+
+
+def precision_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
+ topk = retrieved[:k]
+ if not topk:
+ return 0.0
+ hits = sum(1 for src in topk if src in relevant)
+ return hits / min(k, len(topk))
+
+
+def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
+ for rank, src in enumerate(retrieved, start=1):
+ if src in relevant:
+ return 1.0 / rank
+ return 0.0
+
+
+def hit_rate_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
+ return 1.0 if any(src in relevant for src in retrieved[:k]) else 0.0
+
+
+def evaluate_query(retrieved: list[str], relevant: set[str], ks: tuple[int, ...]) -> dict:
+ result: dict[str, float] = {"mrr": reciprocal_rank(retrieved, relevant)}
+ for k in ks:
+ result[f"recall@{k}"] = recall_at_k(retrieved, relevant, k)
+ result[f"precision@{k}"] = precision_at_k(retrieved, relevant, k)
+ result[f"hit@{k}"] = hit_rate_at_k(retrieved, relevant, k)
+ return result
+
+
+def mean_metrics(rows: list[dict]) -> dict:
+ if not rows:
+ return {}
+ keys = rows[0].keys()
+ return {key: sum(row[key] for row in rows) / len(rows) for key in keys}
diff --git a/assignments/pykido/week4/eval/results_answer.json b/assignments/pykido/week4/eval/results_answer.json
new file mode 100644
index 0000000..34b263b
--- /dev/null
+++ b/assignments/pykido/week4/eval/results_answer.json
@@ -0,0 +1,998 @@
+{
+ "records": [
+ {
+ "id": "q01",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 매개변수 탐색의 정의와 사용 예를 정확히 설명하고 있으며, 검색된 문서의 내용을 잘 반영하고 있다.",
+ "answer": "매개변수 탐색(parametric search)은 이분 탐색의 한 형태로, 특정 조건을 만족하는 최소 또는 최대 값을 찾는 최적화 문제에 사용됩니다. 이 방법은 주어진 후보 값에 대해 그 값이 조건을 만족하는지를 판단하는 결정 함수가 단조(boolean)일 때 적용할 수 있습니다. 예를 들어, 입국심사 문제와 같이 답이 시간에 대해 단조인 경우에 유용합니다.\n\n이분 탐색의 핵심 전제는 단조성으로, 탐색 범위를 절반씩 줄여가며 목표값을 찾는 기법입니다. 매 단계에서 후보 구간의 중앙값을 확인하고, 목표와의 대소 관계에 따라 한쪽 절반을 버립니다. 이 과정에서 매개변수 탐색의 시간 복잡도는 결정 함수 비용이 `O(f)`일 때 `O(f log(범위))`가 됩니다. 따라서 매개변수 탐색은 조건을 만족하는 값을 찾는 데 효과적인 방법입니다.",
+ "sources": [
+ "patterns/binary-search.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서에 기반하여 설명하고 있다.",
+ "answer": "parametric search(매개변수 탐색)은 이분 탐색의 한 형태로, \"조건을 만족하는 최소/최대 값\"을 구하는 최적화 문제에 사용됩니다. 이 방법은 특정 답 후보 x에 대해 `가능한가?(x)`가 단조 boolean일 때, 그 답 자체를 이분 탐색하여 찾습니다. 주로 정렬된 배열에서 특정 값이나 그 삽입 위치를 찾을 때 사용되며, 예를 들어, 입국심사 문제에서 주어진 시간 안에 처리 가능한 인원을 계산하는 경우가 이에 해당합니다. 이 경우, 시간 t가 커질수록 처리 인원도 단조 증가하므로, `f(t) >= n`을 만족하는 최소 t를 이분 탐색하는 방식으로 접근합니다. \n\n시간 복잡도는 탐색 구간이 매 단계 절반으로 줄어들기 때문에 `O(log N)`이며, parametric search의 경우 결정 함수 비용이 `O(f)`일 때 `O(f log(범위))`가 됩니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-43238-immigration.md"
+ ]
+ }
+ },
+ {
+ "id": "q02",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 기반한 설명과 예시 코드가 포함되어 있다.",
+ "answer": "파이썬의 `heapq` 모듈은 최소 힙만을 제공하므로 최대 힙을 구현하려면 값을 음수로 변환하여 넣는 방법을 사용해야 합니다. 예를 들어, 최대 힙에 값을 추가할 때 해당 값에 음수를 취해 `heapq.heappush`를 사용하면 됩니다. 이렇게 하면 최소 힙의 특성을 이용하여 최대 힙처럼 동작하게 할 수 있습니다. 아래는 최대 힙을 구현하는 예시 코드입니다:\n\n```python\nimport heapq\n\nmax_heap = []\n\n# 최대 힙에 값 추가\nheapq.heappush(max_heap, -value) # value를 음수로 변환하여 추가\n\n# 최대 힙에서 최대값 추출\nmax_value = -heapq.heappop(max_heap) # 추출 후 다시 양수로 변환\n``` \n\n이와 같이 `heapq`를 활용하여 최대 힙을 구현할 수 있습니다.",
+ "sources": [
+ "patterns/heap.md",
+ "problems/pgs-42627-disk-controller.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 기반한 설명을 포함하고 있다.",
+ "answer": "파이썬의 `heapq` 모듈은 최소 힙만 제공하므로 최대 힙을 만들기 위해서는 값에 음수를 취해 넣는 방법을 사용합니다. 이렇게 하면 최소 힙의 특성을 이용하여 최대 힙처럼 동작하게 할 수 있습니다. 예를 들어, 다음과 같이 최대 힙을 구현할 수 있습니다:\n\n```python\nimport heapq\n\nmax_heap = []\n\n# 값을 삽입할 때 음수로 변환\nheapq.heappush(max_heap, -value)\n\n# 최대값을 추출할 때도 음수로 변환\nmax_value = -heapq.heappop(max_heap)\n``` \n이 방법은 `O(log N)`의 시간 복잡도로 삽입과 최댓값 추출을 처리할 수 있습니다.",
+ "sources": [
+ "patterns/heap.md",
+ "problems/pgs-42627-disk-controller.md",
+ "patterns/dijkstra.md"
+ ]
+ }
+ },
+ {
+ "id": "q03",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 경로 압축의 역할과 그로 인한 성능 저하를 정확히 설명하고 있으며, 검색된 문서의 내용과 일치한다.",
+ "answer": "유니온 파인드에서 경로 압축을 빼면 성능이 떨어진다는 문제가 발생합니다. 경로 압축은 각 원소가 자기 집합의 대표를 가리키는 트리 구조에서, 트리의 높이를 줄여주는 최적화 기법입니다. 이 최적화를 적용하지 않으면, 트리의 높이가 커져서 '두 원소가 같은 집합에 속하는가'를 확인하는 `find` 연산의 시간이 증가하게 됩니다. 결과적으로, 유니온 파인드의 효율성이 저하되어 거의 상수 시간에 처리할 수 있는 기능이 약해지게 됩니다.",
+ "sources": [
+ "patterns/union-find.md",
+ "patterns/dijkstra.md",
+ "problems/pgs-42861-connecting-islands.md",
+ "patterns/sliding-window.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 의미적으로 일치하며, context 문서에 근거하고 있다.",
+ "answer": "유니온 파인드에서 경로 압축을 빼면 트리가 한쪽으로 길어져서 `find` 연산이 느려지는 문제가 발생합니다. 경로 압축은 트리의 높이를 줄여서 연산의 성능을 개선하는 중요한 최적화 기법입니다. 따라서 경로 압축을 적용하지 않으면, 유니온 파인드의 성능이 떨어지게 됩니다.",
+ "sources": [
+ "patterns/union-find.md",
+ "problems/pgs-42861-connecting-islands.md",
+ "problems/pgs-42898-school-path.md"
+ ]
+ }
+ },
+ {
+ "id": "q04",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서의 내용을 잘 기반으로 하고 있다.",
+ "answer": "가변 슬라이딩 윈도우에서 윈도우를 축소할 때 `if`가 아니라 `while`을 써야 하는 이유는, `if`를 사용하면 한 번의 조건만 확인하고 한 칸만 줄이기 때문에 최소 구간을 놓칠 수 있기 때문입니다. 반면에 `while`을 사용하면 조건이 만족하는 동안 계속해서 윈도우를 축소할 수 있어, 가능한 한 최대한 좁혀서 최소 길이를 찾을 수 있습니다. 이는 가변 슬라이딩 윈도우의 핵심 원칙 중 하나로, 축소 조건을 `while`로 설정하여 모든 조건을 만족할 때까지 반복적으로 축소하는 것이 중요합니다.",
+ "sources": [
+ "patterns/sliding-window.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서의 내용을 잘 기반으로 하고 있다.",
+ "answer": "가변 슬라이딩 윈도우에서 윈도우를 축소할 때 `if`가 아니라 `while`을 써야 하는 이유는, `if`를 사용하면 한 번의 조건 검사로 단지 한 칸만 줄어들게 되기 때문입니다. 이는 최소 구간을 찾는 데 필요한 모든 가능한 축소를 놓칠 수 있습니다. 반면, `while`을 사용하면 조건이 만족하는 동안 계속해서 윈도우를 축소할 수 있어, 최적의 최소 구간을 찾을 수 있습니다. 예를 들어, `while total >= target:`와 같은 형태로 구현하면, `total`이 `target` 이상인 동안 계속해서 `left`를 증가시켜 윈도우를 축소할 수 있습니다. 이 방식은 `O(N^2)`의 비효율적인 계산을 피하고, 선형 시간 복잡도로 문제를 해결할 수 있게 해줍니다.",
+ "sources": [
+ "patterns/sliding-window.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ }
+ },
+ {
+ "id": "q05",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 잘 기반하고 있습니다.",
+ "answer": "구간 `[i, j]`의 합을 O(1)에 구하는 공식은 `prefix[j+1] - prefix[i]`입니다. 이 공식은 배열의 앞에서부터 누적한 합을 미리 계산해 두었을 때 사용할 수 있습니다. 이렇게 하면 구간 합을 여러 번 질의할 때 매번 다시 더하는 O(N) 작업을 O(1)로 줄일 수 있습니다.",
+ "sources": [
+ "patterns/prefix-sum.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 기반한 설명도 포함되어 있다.",
+ "answer": "구간 `[i, j]`의 합을 `O(1)`에 구하는 공식은 `prefix[j+1] - prefix[i]`입니다. 이 공식은 누적 합(Prefix Sum) 패턴을 사용하여, 배열의 앞에서부터 누적한 합을 미리 계산해 두면 여러 번의 구간 합 질의를 효율적으로 처리할 수 있게 해줍니다. 누적 합을 구하는 전처리 과정은 `O(N)`의 시간 복잡도를 가지며, 이후 각 구간 질의는 `O(1)`로 수행할 수 있습니다.",
+ "sources": [
+ "patterns/prefix-sum.md",
+ "problems/pgs-118667-two-queues.md"
+ ]
+ }
+ },
+ {
+ "id": "q06",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 잘 기반하고 있습니다.",
+ "answer": "다익스트라 알고리즘에서 힙에서 꺼낸 거리가 `dist[node]`보다 클 때 건너뛰는 이유는, 이미 확정된 정점의 거리는 다시 바뀌지 않기 때문입니다. 즉, `d > dist[node]`인 경우는 해당 정점에 도달하는 경로가 이미 최단 경로가 아니므로, 중복 처리를 피하고 알고리즘의 효율성을 높이기 위해 해당 노드를 건너뛰어야 합니다. 이 검사를 빠뜨리면 중복 처리로 인해 알고리즘이 느려질 수 있습니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "problems/pgs-43236-stepping-stones.md",
+ "patterns/heap.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 기반한 설명을 포함하고 있다.",
+ "answer": "다익스트라 알고리즘에서 힙에서 꺼낸 거리가 `dist[node]`보다 클 때 건너뛰는 이유는 이미 해당 노드의 최단 거리가 확정되었기 때문입니다. 즉, `d > dist[node]`인 경우, 이 노드는 더 이상 처리할 필요가 없으며, 중복 처리를 피하기 위해 건너뛰어야 합니다. 이렇게 하지 않으면 불필요한 계산이 발생하여 알고리즘의 성능이 저하될 수 있습니다. 다익스트라 알고리즘은 한 번 확정된 정점의 거리는 다시 바뀌지 않는다는 원칙을 따르기 때문에, 이 검사를 통해 효율성을 높이는 것입니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "problems/pgs-42627-disk-controller.md",
+ "patterns/heap.md"
+ ]
+ }
+ },
+ {
+ "id": "q07",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 대향 포인터의 전제 조건을 정확히 설명하고 있으며, 검색된 문서의 내용과 일치한다.",
+ "answer": "투 포인터의 대향 포인터를 적용하기 위해서는 배열이 정렬되어 있어야 한다는 전제가 필요합니다. 대향 포인터는 정렬된 배열의 양 끝에서 안쪽으로 좁혀가는 방식으로 작동하기 때문에, 정렬되지 않은 배열에서는 이 기법을 사용할 수 없습니다. 따라서, 대향 포인터를 사용하기 전에 배열이 정렬되어 있는지 확인하는 것이 중요합니다.",
+ "sources": [
+ "patterns/two-pointers.md",
+ "problems/pgs-42898-school-path.md",
+ "patterns/dijkstra.md",
+ "problems/pgs-118667-two-queues.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 정렬된 배열이 필요하다는 핵심 내용을 정확히 전달하고 있으며, 검색된 문서의 내용과 일치합니다.",
+ "answer": "투 포인터의 대향 포인터를 적용하기 위해서는 **정렬된 배열**이라는 전제가 필요합니다. 정렬되지 않은 배열에 대향 포인터를 적용하면 단조성이 깨져서 오답이 나올 수 있습니다. 따라서, 대향 포인터를 사용하기 전에 배열이 정렬되어 있는지 확인하는 것이 중요합니다.",
+ "sources": [
+ "patterns/two-pointers.md",
+ "problems/pgs-118667-two-queues.md",
+ "patterns/binary-search.md"
+ ]
+ }
+ },
+ {
+ "id": "q08",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서의 내용을 잘 반영하고 있다.",
+ "answer": "그리디 알고리즘이 항상 최적임을 정당화하기 위해서는 **국소 최적 선택이 전역 최적해로 이어진다는 보장**이 필요합니다. 이러한 보장은 교환 논법(exchange argument)이나 매트로이드 성질을 통해 증명할 수 있습니다. 예를 들어, 그리디 선택이 최적임을 증명하지 않고 직관만으로 적용하면, DP가 정답인 문제에 그리디를 사용하여 틀릴 수 있습니다. 따라서 그리디 알고리즘을 적용하기 전에 그 선택이 최적임을 증명하는 과정이 필수적입니다.",
+ "sources": [
+ "patterns/greedy.md",
+ "patterns/binary-search.md",
+ "patterns/dp.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서의 내용을 잘 활용하고 있다.",
+ "answer": "그리디 알고리즘이 항상 최적임을 보장하기 위해서는 **국소 최적 선택이 전역 최적해로 이어진다는 보장**이 필요합니다. 이 보장은 교환 논법(exchange argument)이나 매트로이드 성질을 통해 증명할 수 있습니다. 예를 들어, 활동 선택 문제에서는 시작 시간이 아니라 끝나는 시간 기준으로 정렬하여 선택하는 것이 최적의 해를 보장합니다. 만약 이러한 증명이 없이 그리디를 적용하면, 반례에 의해 틀릴 수 있습니다. 따라서 그리디 알고리즘을 사용할 때는 항상 그 선택이 최적임을 증명하는 과정을 거쳐야 합니다.",
+ "sources": [
+ "patterns/greedy.md",
+ "problems/pgs-43236-stepping-stones.md",
+ "problems/pgs-42627-disk-controller.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ }
+ },
+ {
+ "id": "q09",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, 검색된 context에 기반하고 있습니다.",
+ "answer": "동적 계획법을 적용하기 위한 두 가지 전제 조건은 **최적 부분 구조**와 **중복 부분 문제**입니다. 최적 부분 구조는 큰 문제의 최적해가 부분 문제의 최적해로 구성되어야 하며, 중복 부분 문제는 같은 부분 문제가 여러 번 등장해야 합니다.",
+ "sources": [
+ "patterns/dp.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 근거하고 있습니다.",
+ "answer": "동적 계획법을 적용하기 위한 두 가지 전제 조건은 **최적 부분 구조**와 **중복 부분 문제**입니다. 최적 부분 구조는 큰 문제의 최적해가 부분 문제의 최적해로 구성되어야 하며, 중복 부분 문제는 같은 부분 문제가 여러 번 등장해야 합니다. 이러한 조건이 모두 성립해야 동적 계획법을 효과적으로 사용할 수 있습니다.",
+ "sources": [
+ "patterns/dp.md",
+ "patterns/bfs-dfs.md",
+ "problems/pgs-118667-two-queues.md",
+ "problems/pgs-42627-disk-controller.md"
+ ]
+ }
+ },
+ {
+ "id": "q10",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서의 내용을 잘 반영하고 있다.",
+ "answer": "백트래킹에서 결과에 `path`를 넣을 때 `path[:]`로 복사하는 이유는, `path`를 그대로 참조로 넣으면 이후에 `path`가 변경될 때 결과에도 그 변경이 반영되기 때문입니다. 즉, `path`의 상태가 오염되는 것을 방지하기 위해서입니다. 따라서 `path[:]`를 사용하여 `path`의 복사본을 만들어 결과에 추가해야 합니다. 이는 백트래킹의 흔한 실수 중 하나로, `append`와 `pop`이 짝을 이루어야 하며, 결과를 저장할 때는 반드시 복사본을 사용해야 한다는 점을 강조합니다.",
+ "sources": [
+ "patterns/backtracking.md",
+ "patterns/union-find.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 기반한 설명을 포함하고 있다.",
+ "answer": "백트래킹에서 결과에 `path`를 넣을 때 `path[:]`로 복사하는 이유는, `path`를 그대로 참조로 넣으면 이후에 `path`가 변경될 때 결과에도 그 변경이 반영되기 때문입니다. 따라서, `path[:]`를 사용하여 현재 상태의 복사본을 만들어야 결과가 올바르게 저장됩니다. 이는 흔한 실수 중 하나로, `append`와 `pop`이 짝을 이루어야 하며, 상태가 오염되지 않도록 주의해야 합니다.",
+ "sources": [
+ "patterns/backtracking.md",
+ "problems/pgs-43165-target-number.md",
+ "problems/pgs-42898-school-path.md"
+ ]
+ }
+ },
+ {
+ "id": "q11",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 근거하고 있다.",
+ "answer": "해시 맵의 평균 시간 복잡도는 `O(1)`이며, 최악의 경우는 해시 충돌이 발생할 때 `O(N)`입니다. 이는 해시 함수로 키를 버킷에 분산시켜 평균적으로 빠른 삽입, 조회, 삭제를 지원하지만, 충돌이 많아지면 성능이 저하될 수 있음을 나타냅니다.",
+ "sources": [
+ "patterns/backtracking.md",
+ "patterns/hash-map.md",
+ "patterns/prefix-sum.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 근거하고 있습니다.",
+ "answer": "해시 맵의 평균 시간 복잡도는 `O(1)`이며, 최악의 경우(해시 충돌이 발생할 때) 시간 복잡도는 `O(N)`입니다. 이는 N 개의 원소를 처리할 때 전체적으로 `O(N)`의 복잡도를 가집니다.",
+ "sources": [
+ "patterns/hash-map.md",
+ "patterns/backtracking.md",
+ "problems/pgs-43238-immigration.md",
+ "patterns/dp.md"
+ ]
+ }
+ },
+ {
+ "id": "q12",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 기반한 내용입니다.",
+ "answer": "가중치가 모두 1인 그래프의 최단 거리는 **BFS**를 사용하여 구하는 것이 맞습니다. BFS는 처음 방문하는 시점이 최단 거리이기 때문에, 가중치가 1인 경우에 적합합니다. 반면, DFS는 최단 거리를 구하는 데 적합하지 않습니다.",
+ "sources": [
+ "patterns/bfs-dfs.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 일치하며, context에 기반한 정확한 설명을 제공함.",
+ "answer": "가중치가 모두 1인 그래프의 최단 거리는 **BFS**를 사용하여 구하는 것이 맞습니다. BFS는 처음 방문하는 시점이 최단 거리이기 때문에, 모든 경로를 탐색하는 DFS와는 달리 최단 경로를 효율적으로 찾을 수 있습니다. 따라서, 가중치가 1인 그래프에서는 BFS를 사용하는 것이 적합합니다.",
+ "sources": [
+ "patterns/bfs-dfs.md",
+ "patterns/dijkstra.md"
+ ]
+ }
+ },
+ {
+ "id": "q13",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 설명하고 있으며, context에 기반한 내용을 포함하고 있다.",
+ "answer": "입국심사 문제에서 이분 탐색의 범위의 `hi`를 `max(times) * n`으로 잡는 이유는, 모든 사람이 심사를 마치는 데 걸리는 최대 시간을 충분히 포함하기 위해서입니다. 각 심사대의 처리 시간은 `times` 배열로 주어지며, `max(times)`는 가장 긴 처리 시간을 나타냅니다. 따라서, n명이 모두 심사를 받는 경우, 최악의 상황에서는 모든 사람이 가장 긴 처리 시간을 가진 심사대에서 심사를 받게 될 수 있습니다. 이 경우, 총 소요 시간은 `max(times) * n`이 됩니다. \n\n이렇게 범위를 설정함으로써, 이분 탐색을 통해 최소 시간을 효율적으로 찾을 수 있습니다. 또한, 이 문제는 단조성을 가지므로, 시간이 늘어나면 처리 인원도 늘어난다는 점을 활용하여 lower bound를 찾는 방식으로 진행됩니다.",
+ "sources": [
+ "problems/pgs-43238-immigration.md",
+ "patterns/binary-search.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 의미적으로 일치하며, context에 기반한 설명을 잘 포함하고 있다.",
+ "answer": "입국심사 문제에서 이분 탐색의 범위인 `hi`를 `max(times) * n`으로 잡는 이유는, 모든 사람이 심사를 마치는 데 걸리는 최소 시간을 찾기 위해 충분히 큰 범위를 설정해야 하기 때문입니다. 각 심사대의 처리 시간은 `times` 배열로 주어지며, `max(times)`는 가장 긴 처리 시간을 나타냅니다. 따라서, 만약 모든 심사대가 동시에 작동한다고 가정할 때, 최악의 경우 모든 사람이 가장 긴 시간의 심사대에서 심사를 받는 상황을 고려해야 합니다. 이 경우, 최대 처리 시간에 인원 수 `n`을 곱한 값이 `hi`의 적절한 상한선이 됩니다. \n\n또한, 이 문제는 단조성을 명시해야 하며, 시간이 늘어나면 처리 인원도 늘어난다는 점을 고려해야 합니다. 이로 인해 `f(mid) >= n`일 때 답 후보를 줄이는 방향으로 탐색을 진행할 수 있습니다. 이러한 이유로 `hi`를 `max(times) * n`으로 설정하는 것이 적절합니다.",
+ "sources": [
+ "problems/pgs-43238-immigration.md",
+ "patterns/binary-search.md"
+ ]
+ }
+ },
+ {
+ "id": "q14",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context에 기반한 설명을 포함하고 있다.",
+ "answer": "left를 옮길 때 카운트가 0이 된 보석 종류를 dict에서 삭제해야 하는 이유는, 모든 종류를 포함하고 있는지를 정확하게 판별하기 위해서입니다. 만약 카운트가 0인 종류가 dict에 남아있다면, 이는 여전히 해당 종류가 포함되어 있다고 잘못 판단할 수 있기 때문입니다. 따라서, 카운트가 0이 된 종류는 dict에서 삭제하여 정확한 판별을 가능하게 합니다. 이는 가변 슬라이딩 윈도우와 해시맵을 활용한 보석 쇼핑 문제의 접근 방식에서 중요한 부분입니다.",
+ "sources": [
+ "patterns/hash-map.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 잘 기반하고 있다.",
+ "answer": "left를 옮길 때 카운트가 0이 된 종류를 dict에서 삭제해야 하는 이유는, 모든 종류를 포함하고 있는지를 정확하게 판별하기 위해서입니다. 카운트가 0이 된 보석 종류는 현재 윈도우에 포함되어 있지 않으므로, 이를 dict에서 삭제함으로써 현재 윈도우가 모든 보석 종류를 포함하고 있는지의 여부를 올바르게 판단할 수 있습니다. 이는 가변 슬라이딩 윈도우와 해시맵을 사용하는 접근 방식에서 중요한 부분입니다.",
+ "sources": [
+ "problems/pgs-67258-gem-shopping.md",
+ "patterns/hash-map.md"
+ ]
+ }
+ },
+ {
+ "id": "q15",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 전체 합이 홀수일 때 -1을 반환하는 조건을 정확히 설명하고 있으며, context에 명시된 내용을 잘 반영하고 있다.",
+ "answer": "두 큐 합 같게 만들기 문제에서 -1을 반환하는 조건은 전체 합이 홀수일 때입니다. 이 경우 두 큐의 합이 같아질 수 없기 때문에 -1을 반환하게 됩니다. 따라서, 두 큐의 합이 같아지기 위해서는 전체 합이 짝수여야 합니다.",
+ "sources": [
+ "problems/pgs-118667-two-queues.md",
+ "patterns/two-pointers.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context에 기반하고 있습니다.",
+ "answer": "두 큐의 합이 같게 만들 수 없는 경우는 다음과 같습니다:\n1. 전체 합이 홀수인 경우: 이 경우는 즉시 -1을 반환합니다. \n2. 작업 횟수가 `4 * N`을 초과하는 경우: 이 경우에도 -1로 판별됩니다. \n\n따라서, 두 큐의 합을 같게 만들 수 없는 조건은 전체 합이 홀수이거나, 작업 횟수가 `4 * N`을 넘는 경우입니다.",
+ "sources": [
+ "problems/pgs-118667-two-queues.md",
+ "problems/pgs-42861-connecting-islands.md"
+ ]
+ }
+ },
+ {
+ "id": "q16",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 FCFS와 SJF의 차이를 정확히 설명하고 있으며, context에 기반한 내용이다.",
+ "answer": "디스크 컨트롤러에서 요청을 시각 순서대로 처리하는 FCFS(First-Come, First-Served) 방식은 평균 반환시간을 최소화하지 못하는 이유는 소요 시간 기준 힙을 사용하지 않기 때문입니다. FCFS는 단순히 요청된 순서대로 작업을 처리하기 때문에, 긴 작업이 먼저 도착하면 그 뒤에 오는 짧은 작업들이 대기하게 되어 전체 평균 반환시간이 증가할 수 있습니다. 반면, SJF(Shortest Job First) 스케줄링은 작업 시간이 짧은 것을 우선 처리하여 평균 반환시간을 최소화합니다. 따라서, 요청 시각 순서대로만 처리하는 FCFS 방식은 최적의 결과를 보장하지 않습니다.",
+ "sources": [
+ "problems/pgs-42627-disk-controller.md",
+ "patterns/heap.md",
+ "patterns/hash-map.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 FCFS와 SJF의 차이를 정확히 설명하고 있으며, 모든 주장이 문서에 근거하고 있다.",
+ "answer": "디스크 컨트롤러에서 요청을 순서대로 처리하는 FCFS(First-Come, First-Served) 방식은 평균 반환 시간을 최소화하지 못하는 이유는, 소요 시간 기준으로 작업을 처리하지 않기 때문입니다. FCFS는 단순히 요청 시각에 따라 작업을 처리하지만, 이는 작업의 소요 시간이 긴 경우 뒤에 대기하는 작업들의 대기 시간을 증가시켜 평균 반환 시간을 늘릴 수 있습니다. \n\n반면, SJF(Shortest Job First) 스케줄링 방식은 현재 시각까지 도착한 작업들 중에서 소요 시간이 가장 짧은 작업을 우선적으로 처리합니다. 이렇게 하면 짧은 작업을 먼저 처리하여 뒤에 밀리는 작업들의 대기 시간 합이 줄어들어 평균 반환 시간이 최소화됩니다. \n\n따라서, FCFS 방식은 요청 시각 순서만 고려하기 때문에 최적의 평균 반환 시간을 보장하지 못하는 것입니다.",
+ "sources": [
+ "problems/pgs-42627-disk-controller.md",
+ "patterns/heap.md"
+ ]
+ }
+ },
+ {
+ "id": "q17",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context에 기반한 내용을 포함하고 있다.",
+ "answer": "등굣길 문제의 DP 점화식은 `dp[r][c] = dp[r-1][c] + dp[r][c-1]`로 정의됩니다. 여기서 `dp[r][c]`는 (1,1)에서 (r,c)까지의 경로 수를 나타냅니다. 또한, 나눠야 하는 모듈러 값은 `1e9 + 7`입니다. 이 값은 경우의 수 문제에서 오버플로를 방지하기 위해 사용됩니다.",
+ "sources": [
+ "patterns/dp.md",
+ "problems/pgs-42898-school-path.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 기반한 내용입니다.",
+ "answer": "등굣길 문제의 DP 점화식은 `dp[r][c] = dp[r-1][c] + dp[r][c-1]`입니다. 여기서 `dp[r][c]`는 (1,1)에서 (r,c)까지의 경로 수를 나타냅니다. 또한, 나눠야 하는 모듈러 값은 `1_000_000_007`입니다. 이 값은 중간 오버플로를 방지하기 위해 사용됩니다.",
+ "sources": [
+ "problems/pgs-42898-school-path.md",
+ "patterns/dp.md"
+ ]
+ }
+ },
+ {
+ "id": "q18",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서의 내용도 잘 반영하고 있다.",
+ "answer": "타겟 넘버 문제를 DFS로 풀 때의 base case는 주어진 인덱스가 배열의 길이에 도달했을 때입니다. 이 경우, 현재까지의 누적합이 목표값과 일치하는지를 확인하여 결과를 처리해야 합니다. 이와 관련하여, DFS를 사용할 때 인덱스 경계를 잘못 잡아 한 칸 더 들어가거나 덜 들어가는 실수를 피하는 것이 중요합니다.",
+ "sources": [
+ "problems/pgs-43165-target-number.md",
+ "patterns/bfs-dfs.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서의 내용도 잘 반영하고 있다.",
+ "answer": "타겟 넘버 문제를 DFS로 풀 때의 base case는 `i == len(numbers)`일 때 누적합을 검사하는 것입니다. 이 경우, 누적합이 `target`과 일치하는지 확인하여 경우의 수를 세게 됩니다. 즉, DFS의 재귀 호출이 끝나고 모든 숫자를 사용했을 때, 현재의 누적합이 목표값인 `target`과 같은지를 판단하는 것이 중요합니다.",
+ "sources": [
+ "problems/pgs-43165-target-number.md",
+ "patterns/bfs-dfs.md"
+ ]
+ }
+ },
+ {
+ "id": "q19",
+ "hop": "single",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 징검다리 문제의 이분 탐색 대상에 대한 설명과 context에 잘 부합함.",
+ "answer": "징검다리 문제에서는 최소 점프 거리를 이분 탐색의 대상으로 삼습니다. 이 문제는 주어진 조건을 만족하는 최소 점프 거리의 값을 찾는 것이며, 이 과정에서 이분 탐색을 활용하여 효율적으로 해결할 수 있습니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-42861-connecting-islands.md",
+ "problems/pgs-43236-stepping-stones.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서에 기반한 내용을 포함하고 있습니다.",
+ "answer": "징검다리 문제에서는 \"최소 점프 거리\"를 이분 탐색의 대상으로 삼습니다. 이 문제는 주어진 목표 거리 d가 가능 여부를 결정하는 문제로 변환되며, d가 커질수록 제거해야 할 바위의 수가 증가하는 단조성을 이용하여 최대 d를 이분 탐색으로 찾습니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-43236-stepping-stones.md",
+ "problems/pgs-43238-immigration.md"
+ ]
+ }
+ },
+ {
+ "id": "q20",
+ "hop": "single",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서에 기반한 설명을 제공하고 있다.",
+ "answer": "크루스칼 알고리즘에서 사이클을 검사하는 방법은 union-find 자료구조를 사용하는 것입니다. 이 자료구조를 통해 두 정점이 같은 집합에 속하는지를 확인하여, 간선을 추가할 때 사이클이 발생하는지를 판별합니다. 만약 두 정점이 이미 같은 집합에 속한다면, 해당 간선을 추가하면 사이클이 생기므로 이를 방지해야 합니다. 따라서, 크루스칼 최소 신장 트리에서 간선을 추가할 때 사이클 검사는 union-find를 통해 수행됩니다.",
+ "sources": [
+ "patterns/union-find.md",
+ "patterns/greedy.md",
+ "problems/pgs-42861-connecting-islands.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 정확히 일치하며, context 문서의 내용을 잘 반영하고 있다.",
+ "answer": "크루스칼 알고리즘에서 사이클을 검사하는 방법은 union-find 자료구조를 사용하는 것입니다. 간선을 비용 기준으로 오름차순 정렬한 후, 가장 싼 간선부터 차례로 확인합니다. 이때 두 섬이 이미 같은 집합에 속해 있다면, 추가 시 사이클이 생기므로 그 간선은 건너뛰게 됩니다. 반면, 두 섬이 다른 집합에 속한다면 다리를 놓고 두 집합을 합치는 과정을 진행합니다. 이렇게 함으로써 사이클을 방지할 수 있습니다.",
+ "sources": [
+ "problems/pgs-42861-connecting-islands.md",
+ "patterns/union-find.md"
+ ]
+ }
+ },
+ {
+ "id": "q21",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 입국심사 문제의 parametric search와 이분 탐색의 단조성 개념을 정확히 설명하고 있으며, 모든 주장이 context에 근거하고 있다.",
+ "answer": "입국심사 문제는 parametric search의 예로, 이 문제에서 우리는 특정 시간 내에 입국 심사를 완료할 수 있는 최대 인원 수를 찾고자 합니다. 이때, 시간에 따른 결과가 단조성을 가지기 때문에 이분 탐색을 적용할 수 있습니다. 즉, 만약 주어진 시간에 특정 인원 수를 처리할 수 있다면, 그보다 더 많은 인원 수를 처리할 수 있는 시간도 존재하게 됩니다. 반대로, 특정 인원 수를 처리할 수 없다면 그보다 적은 인원 수를 처리할 수 있는 시간도 존재하지 않게 됩니다. 이러한 단조성의 특성 덕분에 이분 탐색을 통해 효율적으로 답을 찾을 수 있습니다.\n\n이분 탐색의 핵심 전제는 단조성(monotonicity)으로, 이는 탐색 구간이 정렬되어 있거나 결정 함수가 한 방향으로만 변할 때만 적용할 수 있습니다. 입국심사 문제에서 시간에 따른 처리 가능 인원 수의 단조성은 이분 탐색을 통해 최적의 해를 찾는 데 중요한 역할을 합니다.",
+ "sources": [
+ "patterns/binary-search.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 입국심사 문제의 parametric search와 단조성 개념을 정확히 설명하고 있으며, 모든 주장이 검색된 문서에 근거하고 있다.",
+ "answer": "입국심사 문제는 parametric search의 전형적인 예로, 주어진 시간 t에 대해 처리 가능한 인원 수를 계산하는 함수 `f(t)`가 있습니다. 이 함수는 시간 t가 커질수록 처리 가능한 인원 수가 단조 증가하는 성질을 가지고 있습니다. 즉, 시간이 늘어날수록 처리할 수 있는 인원 수가 줄어들지 않기 때문에, 이분 탐색을 통해 최소의 t를 찾는 것이 가능합니다.\n\n이와 같은 단조성은 이분 탐색의 핵심 전제 중 하나로, 탐색 범위를 절반씩 줄여가며 목표값을 찾는 과정에서 필수적입니다. 만약 단조성이 없다면, 이분 탐색을 적용했을 때 잘못된 결과를 초래할 수 있습니다. 따라서 입국심사 문제에서 시간 t에 대한 단조성을 이용하여 최소 t를 찾는 것은 parametric search의 중요한 특징입니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-43238-immigration.md"
+ ]
+ }
+ },
+ {
+ "id": "q22",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 힙의 특성과 디스크 컨트롤러 문제 해결에 대한 설명을 정확히 포함하고 있다.",
+ "answer": "디스크 컨트롤러 문제를 힙으로 푸는 이유는 요청을 도착 시간순으로 보면서 작업 시간이 짧은 것을 우선 처리하기 위해서입니다. 힙 자료구조는 삽입과 최솟값(또는 최댓값) 추출을 모두 `O(log N)`에 처리할 수 있는 완전 이진 트리 기반의 자료구조로, 항상 \"다음으로 처리할 가장 우선순위 높은 원소\"를 빠르게 꺼낼 수 있는 특성을 가지고 있습니다. \n\n디스크 컨트롤러에서는 단순히 요청 시각 순서대로만 처리하는 FCFS 방식으로는 평균 대기 시간을 최소화할 수 없기 때문에, 소요 시간 기준으로 힙을 사용하여 작업을 효율적으로 관리합니다. 또한, 힙이 비었을 때 시간 점프를 고려하여 미래 작업을 미리 실행하는 것도 중요합니다. 이러한 점에서 힙은 디스크 컨트롤러 문제를 해결하는 데 적합한 자료구조입니다.",
+ "sources": [
+ "patterns/heap.md",
+ "patterns/two-pointers.md",
+ "problems/pgs-42627-disk-controller.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 힙의 특성과 디스크 컨트롤러 문제 해결 방식을 정확히 설명하고 있으며, 모든 주장이 검색된 문서에 근거하고 있다.",
+ "answer": "디스크 컨트롤러 문제를 힙으로 푸는 이유는 요청된 작업들을 효율적으로 처리하기 위해서입니다. 힙 자료구조는 삽입과 최솟값(또는 최댓값) 추출을 모두 `O(log N)`의 시간 복잡도로 처리할 수 있는 완전 이진 트리 기반 자료구조입니다. 이 특성 덕분에, 디스크 컨트롤러에서는 현재 시각까지 도착한 작업들을 최소 힙에 넣고, 매 처리 시점에 소요 시간이 가장 짧은 작업을 빠르게 꺼내 실행할 수 있습니다. \n\n이러한 방식은 짧은 작업을 먼저 처리함으로써 뒤에 밀리는 작업들의 대기 시간 합을 줄이는 그리디 알고리즘의 원칙을 따릅니다. 따라서, 힙을 사용함으로써 요청 시각 기준으로 정렬된 작업을 효율적으로 관리하고, 최적의 대기 시간을 달성할 수 있습니다.",
+ "sources": [
+ "patterns/heap.md",
+ "problems/pgs-42627-disk-controller.md"
+ ]
+ }
+ },
+ {
+ "id": "q23",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서의 내용과도 일치합니다.",
+ "answer": "섬 연결하기 문제에서 크루스칼 알고리즘은 두 가지 주요 요소인 그리디 선택과 union-find를 사용합니다. 그리디 선택은 간선을 비용 기준으로 오름차순 정렬하여 가장 저렴한 간선부터 선택하는 방식으로, 이로 인해 전체 비용을 최소화할 수 있습니다. 반면, union-find는 선택한 간선이 사이클을 형성하는지를 검사하는 역할을 합니다. 즉, 같은 집합에 속하는 두 정점을 연결하려고 할 때 사이클이 발생하는지를 확인하여, 사이클이 발생하지 않도록 간선을 건너뛰게 됩니다. 이 두 요소가 결합되어 모든 섬을 연결하는 최소 비용의 간선 집합을 찾는 데 기여합니다.",
+ "sources": [
+ "patterns/union-find.md",
+ "problems/pgs-42861-connecting-islands.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, context 문서에 기반한 설명이다.",
+ "answer": "크루스칼 알고리즘에서 **그리디 선택**은 간선을 비용 오름차순으로 정렬한 후, 가장 싼 간선부터 차례로 선택하는 역할을 합니다. 이는 최소 신장 트리(MST)를 구성하는 데 최적임이 증명되어 있습니다. 반면, **union-find**는 선택한 간선이 두 섬이 이미 같은 집합에 속하는지를 검사하여 사이클이 생기는 것을 방지하는 역할을 합니다. 즉, 두 섬이 연결되어 있지 않다면 다리를 놓고 두 집합을 합치는 과정을 통해 연결을 유지합니다. 이 두 가지 요소가 함께 작용하여 모든 섬을 연결하는 최소 비용의 다리를 찾는 데 기여합니다.",
+ "sources": [
+ "problems/pgs-42861-connecting-islands.md",
+ "patterns/greedy.md"
+ ]
+ }
+ },
+ {
+ "id": "q24",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 흐름과 내용을 정확히 반영하고 있으며, 검색된 문서의 내용에 기반하고 있다.",
+ "answer": "보석 쇼핑 문제를 해결하기 위해 가변 슬라이딩 윈도우와 해시맵을 결합하는 접근 방식을 사용할 수 있습니다. 이 방법은 다음과 같은 흐름으로 진행됩니다.\n\n1. **슬라이딩 윈도우 설정**: 두 개의 포인터 `left`와 `right`를 사용하여 배열의 연속 구간을 표현합니다. `right` 포인터를 늘려가며 현재 윈도우에 포함된 보석의 종류와 개수를 해시맵(딕셔너리)로 추적합니다.\n\n2. **보석 종류 카운트**: `right` 포인터를 이동시키면서 각 보석의 종류별 개수를 해시맵에 기록합니다. 이때, 윈도우 안에 포함된 보석의 종류가 전체 보석 종류를 모두 포함하게 되면, `left` 포인터를 이동시켜 윈도우를 최대한 좁히는 작업을 시작합니다.\n\n3. **최소 길이 갱신**: `left` 포인터를 이동시키면서 윈도우의 길이를 갱신하고, 만약 `left`를 옮길 때 카운트가 0이 되는 보석 종류가 있다면, 해당 종류를 해시맵에서 삭제합니다. 이를 통해 현재 윈도우가 여전히 모든 보석 종류를 포함하는지 확인합니다.\n\n4. **반복**: 이 과정을 반복하여 최종적으로 최소 길이를 찾습니다.\n\n이러한 방식은 슬라이딩 윈도우 기법을 통해 중첩 반복을 피하고, 해시맵을 사용하여 각 보석의 개수를 효율적으로 관리할 수 있게 해줍니다. 이로 인해 시간 복잡도는 O(n)으로 줄어들 수 있습니다.",
+ "sources": [
+ "patterns/hash-map.md",
+ "problems/pgs-67258-gem-shopping.md",
+ "patterns/sliding-window.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, 검색된 context에 기반하여 설명하고 있다.",
+ "answer": "보석 쇼핑 문제를 해결하기 위해 가변 슬라이딩 윈도우와 해시맵을 결합하는 접근 방식을 설명하겠습니다. 이 문제는 모든 종류의 보석을 포함하는 최소 구간을 찾는 것이 목표입니다.\n\n1. **슬라이딩 윈도우 설정**: 두 개의 포인터, `left`와 `right`를 사용하여 현재 구간을 정의합니다. `right` 포인터를 오른쪽으로 이동시키면서 구간을 확장합니다.\n\n2. **해시맵 사용**: 현재 구간에 포함된 보석의 종류와 개수를 세기 위해 해시맵(또는 딕셔너리)을 사용합니다. `right` 포인터가 이동할 때마다 해당 보석의 개수를 해시맵에 기록합니다.\n\n3. **모든 종류 포함 확인**: 현재 구간이 모든 보석 종류를 포함하게 되면, `left` 포인터를 오른쪽으로 이동시켜 구간을 최대한 좁힙니다. 이때, 해시맵에서 보석의 개수를 감소시키고, 만약 어떤 보석의 개수가 0이 되면 해당 보석을 해시맵에서 삭제합니다.\n\n4. **최소 길이 갱신**: 구간이 모든 보석 종류를 포함할 때마다 그 길이를 기록하고, 최소 길이를 갱신합니다.\n\n이러한 방식으로 슬라이딩 윈도우 기법을 활용하여 문제를 효율적으로 해결할 수 있습니다. 이 접근법은 중첩 반복을 피하고 선형 시간 복잡도로 문제를 해결할 수 있게 해줍니다.",
+ "sources": [
+ "problems/pgs-67258-gem-shopping.md",
+ "patterns/sliding-window.md",
+ "patterns/prefix-sum.md"
+ ]
+ }
+ },
+ {
+ "id": "q25",
+ "hop": "multi",
+ "failure_mode": "ambiguous",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 DFS와 BFS의 차이를 정확히 설명하고 있으며, 주어진 문서에 근거하고 있다.",
+ "answer": "타겟 넘버 문제를 DFS(깊이 우선 탐색)로 푸는 이유는 각 원소에 대해 +/- 분기를 통해 모든 가능한 조합을 탐색할 수 있기 때문입니다. DFS는 스택(또는 재귀)을 사용하여 한 경로를 끝까지 파고든 뒤 되돌아오는 방식으로, 모든 경우의 수를 효율적으로 탐색할 수 있습니다. 반면, BFS(너비 우선 탐색)는 큐를 사용하여 시작점에서 가까운 노드부터 동심원처럼 퍼져 나가는데, 이 방식은 모든 부분합을 큐에 쌓아 메모리가 폭발할 위험이 있습니다. 따라서 타겟 넘버 문제에서는 DFS가 더 적합한 접근 방식입니다.",
+ "sources": [
+ "patterns/bfs-dfs.md",
+ "problems/pgs-43165-target-number.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 문제의 특성과 DFS/BFS의 차이를 정확히 설명하고 있으며, 모든 주장이 문서에 근거하고 있다.",
+ "answer": "타겟 넘버 문제를 DFS/백트래킹으로 푸는 이유는 이 문제의 특성상 각 원소에 대해 `+` 또는 `-`를 붙이는 두 가지 선택이 존재하기 때문입니다. DFS(Depth-First Search) 방식은 이러한 선택을 트리 형태로 탐색하면서, 현재 경로가 유망하지 않다고 판단되면 즉시 되돌아가는 가지치기(pruning) 기법을 사용할 수 있습니다. 이는 불필요한 탐색을 줄여주어 효율적인 해결을 가능하게 합니다. \n\n반면, BFS(Breadth-First Search)로 접근할 경우, 모든 부분합을 큐에 쌓아야 하므로 메모리 사용량이 급격히 증가할 수 있습니다. 특히, 각 단계에서 모든 가능한 조합을 큐에 저장하게 되면 메모리가 폭발할 위험이 있습니다. 따라서 타겟 넘버 문제는 DFS/백트래킹 방식이 더 적합하며, BFS는 비효율적입니다.",
+ "sources": [
+ "patterns/bfs-dfs.md",
+ "patterns/backtracking.md",
+ "problems/pgs-43165-target-number.md"
+ ]
+ }
+ },
+ {
+ "id": "q26",
+ "hop": "multi",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 중복 부분 문제와 메모이제이션의 필요성을 정확히 설명하고 있으며, 검색된 문서의 내용과 일치한다.",
+ "answer": "등굣길 문제를 단순 DFS 재귀로 풀면 안 되는 이유는 DP의 중복 부분 문제 개념과 관련이 있습니다. DFS를 사용하면 경로를 탐색할 때 같은 경로를 여러 번 방문하게 되어, 경로 수가 지수적으로 폭발할 수 있습니다. 이는 메모이제이션을 사용하지 않으면 중복된 계산이 발생하기 때문입니다. 예를 들어, 특정 위치에서 여러 번 같은 경로를 계산하게 되면, 그 결과를 저장하지 않으면 매번 같은 계산을 반복하게 되어 비효율적입니다. 따라서 DP를 활용하여 중복된 부분 문제를 해결하고, 이미 계산된 경로 수를 저장함으로써 효율성을 높이는 것이 중요합니다. 이와 같은 이유로, 등굣길 문제는 DFS 재귀만으로 해결하기보다는 DP를 적용하여 중복 계산을 피하는 것이 바람직합니다.",
+ "sources": [
+ "problems/pgs-42898-school-path.md",
+ "problems/pgs-43165-target-number.md",
+ "patterns/bfs-dfs.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 중복 부분 문제와 메모이제이션의 필요성을 정확히 설명하고 있으며, context에 잘 기반하고 있다.",
+ "answer": "등굣길 문제를 단순 DFS 재귀로 풀면 안 되는 이유는 DP의 중복 부분 문제 개념과 관련이 있습니다. DFS는 한 경로를 끝까지 탐색한 후 되돌아오는 방식으로 작동하는데, 이 과정에서 동일한 경로를 여러 번 탐색하게 됩니다. 예를 들어, 같은 위치에서 여러 번 경로를 계산하게 되면, 그 결과가 중복되어 계산되는 문제가 발생합니다. 이는 지수적으로 경로 수가 폭발하게 만들며, 효율적이지 않습니다.\n\n반면, 동적 계획법(DP)은 큰 문제를 겹치는 작은 부분 문제로 나누고, 이러한 부분 문제의 답을 저장해 재사용하는 기법입니다. DP의 두 가지 전제 중 하나인 **중복 부분 문제**가 등굣길 문제에 적용되면, 동일한 경로의 계산 결과를 메모이제이션을 통해 저장하고 재사용함으로써 불필요한 계산을 줄일 수 있습니다. 따라서, DP를 사용하면 시간 복잡도를 크게 줄일 수 있습니다.",
+ "sources": [
+ "patterns/dp.md",
+ "problems/pgs-42898-school-path.md",
+ "patterns/bfs-dfs.md",
+ "patterns/backtracking.md"
+ ]
+ }
+ },
+ {
+ "id": "q27",
+ "hop": "multi",
+ "failure_mode": "ambiguous",
+ "baseline": {
+ "correctness": 3,
+ "groundedness": 4,
+ "reason": "입국심사와 징검다리의 차이를 설명했으나, 핵심적인 lower bound와 upper bound 개념을 명확히 언급하지 않음.",
+ "answer": "징검다리와 입국심사는 모두 이분 탐색을 사용하지만, 찾는 경계가 다릅니다. \n\n- **입국심사**는 주어진 심사대의 처리 시간 배열 `times`를 기반으로, 모든 사람이 심사를 마치는 데 걸리는 **최소 시간**을 찾는 문제입니다. 이 경우, 답이 시간에 대해 단조롭게 증가하는 성질을 가지고 있어, 이분 탐색을 통해 최소 시간을 효율적으로 찾을 수 있습니다.\n\n- **징검다리**는 최소 점프 거리를 이분 탐색하는 문제로, 주어진 조건을 만족하는 최소 점프 거리의 값을 찾습니다. 이 경우, 찾는 값은 점프 거리이며, 이 역시 이분 탐색을 통해 최적의 값을 찾는 방식으로 접근합니다.\n\n따라서, 입국심사는 시간의 최소값을 찾는 반면, 징검다리는 점프 거리의 최소값을 찾는다는 점에서 차이가 있습니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-43238-immigration.md",
+ "problems/pgs-43236-stepping-stones.md",
+ "problems/pgs-42861-connecting-islands.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 두 문제의 이분 탐색 방식과 목표를 정확히 설명하고 있으며, 검색된 문서의 내용을 잘 반영하고 있다.",
+ "answer": "징검다리와 입국심사는 모두 이분 탐색을 사용하지만, 찾는 경계가 다릅니다. \n\n1. **입국심사**: 이 문제는 주어진 심사대의 처리 시간에 따라 모든 사람이 심사를 마치는 최소 시간을 찾는 문제입니다. 이 경우, 탐색 범위는 `lo = 1`, `hi = max(times) * n`으로 설정하고, 단조성을 이용하여 시간이 늘어날수록 처리 인원도 늘어난다는 점을 활용합니다. 이분 탐색을 통해 `f(mid) >= n`이 성립하는 최소 시간을 찾습니다. 즉, **최소 시간**을 찾는 것이 목표입니다.\n\n2. **징검다리**: 이 문제는 최소 점프 거리가 d 이상이 되도록 만들 수 있는지를 결정하는 문제입니다. 주어진 거리 d가 커질수록 제거해야 할 바위의 수가 많아지므로, 단조성이 성립합니다. 이분 탐색을 통해 **최대 d**를 찾는 것이 목표입니다. 즉, 가능한 최대 점프 거리를 찾는 것입니다.\n\n결론적으로, 입국심사는 최소 시간을 찾는 반면, 징검다리는 최대 점프 거리를 찾는 차이가 있습니다.",
+ "sources": [
+ "patterns/binary-search.md",
+ "problems/pgs-43238-immigration.md",
+ "problems/pgs-43236-stepping-stones.md"
+ ]
+ }
+ },
+ {
+ "id": "q28",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 핵심 내용을 정확히 반영하고 있으며, 검색된 context에 기반하여 설명하고 있다.",
+ "answer": "두 큐 합 같게 만들기 문제는 투 포인터 기법과 밀접하게 연결되어 있습니다. 이 문제에서는 두 큐를 덱으로 사용하고, 한 큐의 합이 더 클 경우 그 큐의 앞 원소를 빼서 다른 큐의 뒤에 넣는 방식으로 진행합니다. 이는 투 포인터의 한 방향으로만 이동하는 방식과 유사합니다. 즉, 두 큐의 합을 맞추기 위해 한 큐에서 원소를 제거하고 다른 큐에 추가하는 과정을 반복하며, 이 과정에서 합이 같아지면 종료합니다. 이 접근 방식은 전체 합이 홀수일 경우에는 절대 같아질 수 없다는 점도 고려해야 하며, 합 갱신을 `O(1)`로 하는 것이 핵심입니다. 따라서, 두 큐 합 같게 만들기 문제는 투 포인터 기법을 활용하여 효율적으로 해결할 수 있는 문제입니다.",
+ "sources": [
+ "patterns/two-pointers.md",
+ "problems/pgs-118667-two-queues.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer와 의미적으로 일치하며, context에 기반한 내용을 잘 설명하고 있다.",
+ "answer": "두 큐 합 같게 만들기 문제는 투 포인터 기법과 밀접하게 연결되어 있습니다. 이 문제에서는 두 큐의 합이 다를 때, 한 큐의 앞 원소를 빼서 다른 큐의 뒤에 넣는 방식으로 두 큐의 합을 맞추는 과정을 진행합니다. 이는 투 포인터 기법의 한 형태로, 한 방향으로만 원소를 이동시키는 방식이기 때문에 동방향 포인터의 특성을 가지고 있습니다. \n\n특히, 두 큐의 합이 같아질 때까지 반복적으로 원소를 이동시키는 과정에서, 각 큐의 합을 `O(1)`로 갱신하는 것이 핵심입니다. 이처럼 두 큐를 덱으로 사용하고, 포인터처럼 원소를 이동시키는 방식은 투 포인터 기법의 적용 예시로 볼 수 있습니다. 따라서, 두 큐 합 같게 만들기 문제는 투 포인터 기법을 활용하여 효율적으로 해결할 수 있는 문제입니다.",
+ "sources": [
+ "problems/pgs-118667-two-queues.md",
+ "patterns/two-pointers.md"
+ ]
+ }
+ },
+ {
+ "id": "q29",
+ "hop": "multi",
+ "failure_mode": "hallucination",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 다익스트라 알고리즘의 우선순위 큐 사용 이유와 시간 복잡도를 정확히 설명하고 있으며, 모든 주장이 검색된 문서에 근거하고 있다.",
+ "answer": "다익스트라 알고리즘이 우선순위 큐(힙)를 사용하는 이유는 매 순간 최단 경로를 찾기 위해 \"다음으로 처리할 가장 우선순위 높은 원소\"를 빠르게 꺼내야 하기 때문입니다. 힙은 삽입과 최솟값(또는 최댓값) 추출을 모두 `O(log N)`에 처리할 수 있는 자료구조로, 다익스트라 알고리즘에서는 간선마다 최대 한 번 힙에 들어가므로 전체 시간 복잡도는 `O((V + E) log V)`가 됩니다. 이러한 특성 덕분에 다익스트라 알고리즘은 효율적으로 최단 경로를 계산할 수 있습니다.",
+ "sources": [
+ "patterns/heap.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 다익스트라 알고리즘의 우선순위 큐 사용 이유를 정확히 설명하고 있으며, 모든 주장이 검색된 문서에 근거하고 있다.",
+ "answer": "다익스트라 알고리즘은 음수 가중치가 없는 그래프에서 한 시작점으로부터 모든 정점까지의 최단 거리를 구하는 알고리즘입니다. 이 알고리즘에서 우선순위 큐(힙)를 사용하는 이유는 아직 확정되지 않은 정점 중에서 거리가 가장 짧은 정점을 빠르게 꺼내기 위해서입니다. 우선순위 큐는 삽입과 최솟값 추출을 모두 `O(log N)`에 처리할 수 있는 자료구조로, 다익스트라 알고리즘에서 최단 경로를 찾는 과정에서 매우 효율적입니다. 이렇게 함으로써, 정점을 확정하고 인접 정점의 거리를 갱신하는 과정이 원활하게 이루어질 수 있습니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "patterns/heap.md"
+ ]
+ }
+ },
+ {
+ "id": "q30",
+ "hop": "multi",
+ "failure_mode": "wrong_retrieval",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 누적 합과 해시맵을 활용한 원리를 정확히 설명하고 있으며, 검색된 문서의 내용과 일치한다.",
+ "answer": "합이 K인 부분 배열의 개수를 세는 방법은 누적 합과 해시맵을 활용하는 방식입니다. 이 방법은 다음과 같은 원리로 작동합니다.\n\n1. **누적 합 계산**: 배열의 각 원소를 순회하면서 현재까지의 누적 합을 계산합니다. 이 누적 합은 배열의 시작부터 현재 인덱스까지의 합을 나타냅니다.\n\n2. **해시맵 사용**: 누적 합을 해시맵에 저장하여 각 누적 합의 등장 횟수를 기록합니다. 이 해시맵은 특정 누적 합이 몇 번 발생했는지를 추적하는 데 사용됩니다.\n\n3. **조건 확인**: 현재 누적 합에서 K를 뺀 값이 해시맵에 존재하는지 확인합니다. 만약 존재한다면, 이는 현재 인덱스까지의 부분 배열 중 합이 K인 부분 배열의 개수를 의미합니다. 즉, 현재 누적 합에서 K를 뺀 값이 이전에 나타난 누적 합이라면, 그 차이만큼의 부분 배열이 존재하는 것입니다.\n\n4. **결과 업데이트**: 해시맵에 현재 누적 합을 추가하고, 합이 K인 부분 배열의 개수를 누적하여 최종 결과를 도출합니다.\n\n이러한 방식은 시간 복잡도가 O(N)으로 효율적이며, 해시맵을 사용하여 누적 합의 빈도를 관리함으로써 빠르게 결과를 도출할 수 있습니다. 이 방법은 \"합이 K인 부분 배열의 개수\" 문제에 적합한 접근법입니다.",
+ "sources": [
+ "patterns/prefix-sum.md",
+ "problems/pgs-118667-two-queues.md",
+ "patterns/two-pointers.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 누적 합과 해시맵을 이용한 원리를 정확히 설명하고 있으며, 모든 주장이 검색된 문서에 근거하고 있다.",
+ "answer": "합이 K인 부분 배열의 개수를 세는 방법은 누적 합과 해시맵을 활용하는 방식입니다. 이 방법은 배열의 각 요소를 순회하면서 현재까지의 누적 합을 계산하고, 이 누적 합을 해시맵에 저장하여 이전에 계산된 누적 합과의 차이를 이용해 K를 찾는 원리입니다.\n\n1. **누적 합 계산**: 배열의 각 요소를 순회하면서 누적 합을 계산합니다. 이때, `total`이라는 변수를 사용하여 현재까지의 누적 합을 저장합니다.\n\n2. **해시맵 사용**: 해시맵(`seen`)을 사용하여 각 누적 합이 몇 번 등장했는지를 기록합니다. 초기에는 `seen[0] = 1`로 설정하여 누적 합이 K인 경우를 처리할 수 있도록 합니다.\n\n3. **부분 배열 개수 세기**: 현재 누적 합에서 K를 뺀 값(`total - k`)이 해시맵에 존재한다면, 이는 현재까지의 누적 합에서 K를 만들 수 있는 이전의 누적 합이 존재함을 의미합니다. 이 경우, 해당 값의 등장 횟수를 `count`에 더합니다.\n\n4. **해시맵 업데이트**: 현재 누적 합을 해시맵에 추가하여 다음 반복에서 사용할 수 있도록 합니다.\n\n이 과정을 통해 배열을 한 번만 순회하면서 O(N) 시간 복잡도로 합이 K인 부분 배열의 개수를 구할 수 있습니다. 아래는 이 원리를 구현한 코드입니다:\n\n```python\nfrom collections import defaultdict\n\ndef subarray_sum_k(nums, k):\n count = total = 0\n seen = defaultdict(int)\n seen[0] = 1\n for x in nums:\n total += x\n count += seen[total - k]\n seen[total] += 1\n return count\n``` \n\n이 방법은 음수가 포함된 배열에서도 유효하며, 누적 합과 해시맵을 결합하여 효율적으로 문제를 해결할 수 있습니다.",
+ "sources": [
+ "patterns/prefix-sum.md"
+ ]
+ }
+ },
+ {
+ "id": "q31",
+ "hop": "multi",
+ "failure_mode": "ambiguous",
+ "baseline": {
+ "correctness": 3,
+ "groundedness": 1,
+ "reason": "답변은 다익스트라와 벨만-포드 알고리즘을 언급했지만, 검색된 문서와 관련이 없고, 질문의 맥락에 대한 언급이 없다.",
+ "answer": "최단 경로 문제는 일반적으로 **다익스트라 알고리즘**이나 **벨만-포드 알고리즘**을 사용하여 해결합니다. 다익스트라 알고리즘은 가중치가 있는 그래프에서 한 정점에서 다른 모든 정점까지의 최단 경로를 찾는 데 유용하며, 벨만-포드 알고리즘은 음수 가중치가 있는 경우에도 사용할 수 있습니다. 이 두 알고리즘은 최단 경로 문제를 해결하는 데 널리 사용되는 방법입니다.",
+ "sources": [
+ "problems/pgs-42898-school-path.md",
+ "problems/pgs-118667-two-queues.md",
+ "patterns/backtracking.md",
+ "patterns/union-find.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 다익스트라 알고리즘의 정의와 작동 방식을 정확히 설명하고 있으며, 검색된 문서의 내용과 일치합니다.",
+ "answer": "최단 경로 문제는 **다익스트라(Dijkstra)** 알고리즘으로 풉니다. 이 알고리즘은 음수 가중치가 없는 그래프에서 한 시작점으로부터 모든 정점까지의 최단 거리를 구하는 방법입니다. 다익스트라 알고리즘은 아직 확정되지 않은 정점 중에서 거리가 가장 짧은 것을 우선순위 큐로 꺼내 확정하고, 그 정점을 거쳐 가는 경로로 인접 정점의 거리를 갱신(relaxation)하는 방식으로 작동합니다. 한 번 확정된 정점의 거리는 다시 바뀌지 않습니다. 이 알고리즘은 가중 그래프 최단 경로 문제, 배달 문제(여러 목적지까지 최단 거리), 최소 비용 격자 이동 등과 같은 연관 문제에서도 사용됩니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "patterns/heap.md",
+ "patterns/backtracking.md"
+ ]
+ }
+ },
+ {
+ "id": "q32",
+ "hop": "single",
+ "failure_mode": "ambiguous",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 내용을 정확히 반영하고 있으며, 검색된 context에 기반하여 설명하고 있다.",
+ "answer": "윈도우를 옮기면서 연속 구간을 다루는 문제는 주로 **슬라이딩 윈도우 (Sliding Window)** 패턴을 사용하여 해결할 수 있습니다. 이 패턴은 배열이나 문자열의 연속 구간을 두 포인터(left, right)로 표현하고, right를 늘려 구간을 확장하거나 left를 늘려 구간을 축소하면서 구간 통계를 유지하는 기법입니다. 예를 들어, \"길이 K 인 연속 구간의 합/최댓값\" 같은 고정 크기 윈도우 문제나, \"조건을 만족하는 가장 긴/짧은 연속 구간\" 같은 가변 크기 윈도우 문제에서 이 패턴을 적용할 수 있습니다. 또한, 부분 문자열 문제에서 문자 빈도를 해시맵으로 추적할 때도 유용합니다.",
+ "sources": [
+ "patterns/sliding-window.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 reference answer의 내용과 일치하며, 검색된 문서에 기반하여 구체적인 예시를 제공하고 있다.",
+ "answer": "윈도우를 옮기면서 연속 구간을 다루는 문제로는 다음과 같은 것들이 있습니다:\n\n1. **고정 길이 부분합의 최댓값**: 길이 K인 연속 구간의 합이나 최댓값을 구하는 문제입니다. 이 경우 슬라이딩 윈도우 기법을 사용하여 효율적으로 구할 수 있습니다.\n\n2. **중복 없는 가장 긴 부분 문자열**: 주어진 문자열에서 중복된 문자가 없는 가장 긴 부분 문자열을 찾는 문제로, 가변 크기 윈도우를 활용하여 해결할 수 있습니다.\n\n3. **보석 쇼핑**: 모든 종류의 보석을 포함하는 최소 구간을 찾는 문제로, 가변 슬라이딩 윈도우와 해시맵을 사용하여 접근합니다. 이 문제에서는 오른쪽 포인터를 늘리며 보석 종류별 개수를 세고, 전체 종류를 포함하면 왼쪽 포인터를 늘려 최소 길이를 갱신합니다.\n\n이와 같은 문제들은 슬라이딩 윈도우 패턴을 통해 효율적으로 해결할 수 있습니다.",
+ "sources": [
+ "patterns/sliding-window.md",
+ "problems/pgs-67258-gem-shopping.md"
+ ]
+ }
+ },
+ {
+ "id": "q33",
+ "hop": "none",
+ "failure_mode": "out_of_domain",
+ "baseline": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 근거가 없음을 정확히 인지하고 있습니다.",
+ "answer": "죄송하지만, 세그먼트 트리로 구간 최솟값 질의(RMQ)를 구현하는 방법에 대한 정보는 제공된 문서에 포함되어 있지 않습니다. 관련된 내용을 찾을 수 없습니다.",
+ "sources": [
+ "problems/pgs-118667-two-queues.md",
+ "patterns/dp.md",
+ "patterns/sliding-window.md",
+ "patterns/dijkstra.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 1,
+ "groundedness": 1,
+ "reason": "답변은 세그먼트 트리에 대한 구체적인 설명을 제공하지만, 검색된 문서와 관련이 없고 지식베이스에 없는 내용을 포함하고 있다.",
+ "answer": "세그먼트 트리는 구간 최솟값 질의(RMQ)를 효율적으로 처리할 수 있는 자료구조입니다. 세그먼트 트리를 사용하면 구간의 최솟값을 구하는 질의를 `O(log N)`의 시간 복잡도로 수행할 수 있습니다.\n\n### 세그먼트 트리 구현 방법\n1. **트리 구조 정의**: 세그먼트 트리는 완전 이진 트리 형태로 구성되며, 각 노드는 특정 구간의 최솟값을 저장합니다.\n2. **트리 생성**: 주어진 배열을 기반으로 세그먼트 트리를 생성합니다. 각 노드는 자식 노드의 값을 비교하여 최솟값을 저장합니다.\n3. **질의 처리**: 특정 구간의 최솟값을 구하기 위해, 해당 구간에 포함되는 노드들을 찾아서 그 값들을 비교하여 최솟값을 반환합니다.\n\n### 코드 예시\n```python\nclass SegmentTree:\n def __init__(self, data):\n self.n = len(data)\n self.tree = [0] * (2 * self.n)\n # 리프 노드 초기화\n for i in range(self.n):\n self.tree[self.n + i] = data[i]\n # 내부 노드 초기화\n for i in range(self.n - 1, 0, -1):\n self.tree[i] = min(self.tree[i * 2], self.tree[i * 2 + 1])\n\n def range_min_query(self, left, right):\n result = float('inf')\n left += self.n\n right += self.n\n while left < right:\n if left & 1:\n result = min(result, self.tree[left])\n left += 1\n if right & 1:\n right -= 1\n result = min(result, self.tree[right])\n left //= 2\n right //= 2\n return result\n```\n\n이 코드는 세그먼트 트리를 초기화하고, 주어진 구간의 최솟값을 반환하는 `range_min_query` 메서드를 포함하고 있습니다. 이처럼 세그먼트 트리를 활용하면 구간 최솟값 질의를 효율적으로 처리할 수 있습니다.",
+ "sources": [
+ "patterns/heap.md",
+ "patterns/binary-search.md",
+ "patterns/prefix-sum.md",
+ "patterns/sliding-window.md"
+ ]
+ }
+ },
+ {
+ "id": "q34",
+ "hop": "none",
+ "failure_mode": "out_of_domain",
+ "baseline": {
+ "correctness": 1,
+ "groundedness": 1,
+ "reason": "답변은 벨만-포드 알고리즘의 의사코드를 제공하지만, 검색된 문서에서는 해당 알고리즘에 대한 정보가 없으므로 근거가 없다.",
+ "answer": "벨만-포드 알고리즘의 의사코드는 다음과 같습니다:\n\n1. 거리 배열 `dist`를 초기화합니다. 모든 정점의 거리를 무한대로 설정하고, 시작 정점의 거리는 0으로 설정합니다.\n2. 모든 간선에 대해 반복합니다. (정점의 개수 - 1)번 반복합니다.\n - 각 간선 (u, v)와 가중치 w에 대해 다음을 수행합니다:\n - 만약 `dist[u] + w < dist[v]`라면, `dist[v]`를 `dist[u] + w`로 갱신합니다.\n3. 음수 사이클을 체크합니다. 모든 간선에 대해 다시 한 번 반복하여 거리 배열이 갱신되는지 확인합니다. 만약 갱신된다면 음수 사이클이 존재합니다.\n\n이 알고리즘은 음수 가중치 간선이 있는 그래프에서 최단 경로를 찾는 데 유용합니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "problems/pgs-42898-school-path.md",
+ "problems/pgs-43165-target-number.md",
+ "problems/pgs-43238-immigration.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 1,
+ "groundedness": 1,
+ "reason": "답변은 벨만-포드 알고리즘의 의사코드를 제공하지만, 검색된 문서에서 벨만-포드에 대한 내용이 없으므로 근거가 없다.",
+ "answer": "벨만-포드 알고리즘은 음수 가중치 간선이 있는 그래프에서 최단 경로를 찾기 위해 사용됩니다. 아래는 벨만-포드 알고리즘의 의사코드입니다:\n\n```\nfunction BellmanFord(graph, source):\n // 1. 거리 배열 초기화\n for each vertex v in graph:\n dist[v] = ∞\n dist[source] = 0\n\n // 2. 간선 Relaxation\n for i from 1 to |V| - 1:\n for each edge (u, v) in graph:\n if dist[u] + weight(u, v) < dist[v]:\n dist[v] = dist[u] + weight(u, v)\n\n // 3. 음수 사이클 체크\n for each edge (u, v) in graph:\n if dist[u] + weight(u, v) < dist[v]:\n return \"Negative weight cycle detected\"\n\n return dist\n```\n\n이 알고리즘은 정점 수를 V, 간선 수를 E라고 할 때 시간 복잡도는 `O(V * E)`입니다. 벨만-포드 알고리즘은 음수 가중치 그래프에 적용해야 한다는 점에서 다른 최단 경로 알고리즘과 차별화됩니다.",
+ "sources": [
+ "patterns/dijkstra.md",
+ "patterns/bfs-dfs.md",
+ "problems/pgs-118667-two-queues.md"
+ ]
+ }
+ },
+ {
+ "id": "q35",
+ "hop": "none",
+ "failure_mode": "out_of_domain",
+ "baseline": {
+ "correctness": 1,
+ "groundedness": 1,
+ "reason": "답변은 정렬 알고리즘에 대한 내용을 포함하고 있지만, 주어진 context에는 관련 정보가 없으므로 잘못된 내용을 지어냈다.",
+ "answer": "퀵 정렬과 병합 정렬은 모두 효율적인 정렬 알고리즘이지만, 그 동작 방식과 성능에서 차이가 있습니다.\n\n### 퀵 정렬 (Quick Sort)\n- **원리**: 퀵 정렬은 분할 정복 알고리즘으로, 피벗을 선택하고 피벗보다 작은 원소와 큰 원소로 배열을 나누는 방식으로 작동합니다. \n- **시간 복잡도**: 평균적으로 `O(N log N)`의 시간 복잡도를 가지지만, 최악의 경우 `O(N^2)`이 될 수 있습니다. \n\n### 병합 정렬 (Merge Sort)\n- **원리**: 병합 정렬 역시 분할 정복 알고리즘이지만, 배열을 반으로 나누고 각각을 정렬한 후 다시 합치는 방식으로 작동합니다. \n- **시간 복잡도**: 항상 `O(N log N)`의 시간 복잡도를 보장합니다.\n\n### 주요 차이점\n- **정렬 방식**: 퀵 정렬은 피벗을 기준으로 나누고, 병합 정렬은 배열을 반으로 나누어 정렬 후 합칩니다.\n- **최악의 경우 성능**: 퀵 정렬은 최악의 경우 성능이 떨어질 수 있지만, 병합 정렬은 항상 일정한 성능을 유지합니다.",
+ "sources": [
+ "patterns/two-pointers.md",
+ "problems/pgs-118667-two-queues.md",
+ "patterns/greedy.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 근거가 없음을 정확히 인지하고 있습니다.",
+ "answer": "퀵 정렬과 병합 정렬의 차이에 대한 구체적인 내용은 context에 포함되어 있지 않습니다. 따라서 이 두 정렬 알고리즘의 동작 방식이나 시간 복잡도에 대해서는 알 수 없습니다. 추가적인 정보가 필요합니다.",
+ "sources": [
+ "patterns/heap.md",
+ "problems/pgs-42861-connecting-islands.md",
+ "patterns/greedy.md",
+ "patterns/hash-map.md"
+ ]
+ }
+ },
+ {
+ "id": "q36",
+ "hop": "none",
+ "failure_mode": "out_of_domain",
+ "baseline": {
+ "correctness": 3,
+ "groundedness": 2,
+ "reason": "답변은 트라이의 일반적인 용도에 대해 설명했지만, 제공된 context와는 관련이 없어서 groundedness 점수가 낮음.",
+ "answer": "트라이(Trie) 자료구조는 주로 문자열 검색 및 저장과 관련된 문제에 사용됩니다. 예를 들어, 단어의 접두사(prefix)를 효율적으로 관리할 수 있어, 자동 완성 기능이나 사전 검색과 같은 응용 프로그램에서 유용합니다. 하지만 제공된 context에는 트라이 자료구조와 관련된 구체적인 문제나 예시는 포함되어 있지 않습니다.",
+ "sources": [
+ "patterns/bfs-dfs.md",
+ "patterns/backtracking.md",
+ "patterns/prefix-sum.md",
+ "patterns/union-find.md"
+ ]
+ },
+ "agentic": {
+ "correctness": 5,
+ "groundedness": 5,
+ "reason": "답변이 정확하게 근거가 없음을 인정하고 있다.",
+ "answer": "모른다.",
+ "sources": [
+ "patterns/heap.md",
+ "patterns/union-find.md",
+ "problems/pgs-42861-connecting-islands.md",
+ "patterns/backtracking.md"
+ ]
+ }
+ }
+ ],
+ "summary": {
+ "baseline": {
+ "all": {
+ "n": 36,
+ "correctness": 4.611,
+ "groundedness": 4.556
+ },
+ "single": {
+ "n": 21,
+ "correctness": 5.0,
+ "groundedness": 5.0
+ },
+ "multi": {
+ "n": 11,
+ "correctness": 4.636,
+ "groundedness": 4.545
+ },
+ "none": {
+ "n": 4,
+ "correctness": 2.5,
+ "groundedness": 2.25
+ }
+ },
+ "agentic": {
+ "all": {
+ "n": 36,
+ "correctness": 4.778,
+ "groundedness": 4.778
+ },
+ "single": {
+ "n": 21,
+ "correctness": 5.0,
+ "groundedness": 5.0
+ },
+ "multi": {
+ "n": 11,
+ "correctness": 5.0,
+ "groundedness": 5.0
+ },
+ "none": {
+ "n": 4,
+ "correctness": 3.0,
+ "groundedness": 3.0
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/assignments/pykido/week4/eval/results_retrieval.json b/assignments/pykido/week4/eval/results_retrieval.json
new file mode 100644
index 0000000..bfd7fb3
--- /dev/null
+++ b/assignments/pykido/week4/eval/results_retrieval.json
@@ -0,0 +1,152 @@
+{
+ "vector": {
+ "all": {
+ "n": 32,
+ "mrr": 0.8838541666666667,
+ "recall@1": 0.6354166666666666,
+ "precision@1": 0.8125,
+ "hit@1": 0.8125,
+ "recall@3": 0.8697916666666666,
+ "precision@3": 0.6458333333333333,
+ "hit@3": 0.9375,
+ "recall@5": 0.9427083333333334,
+ "precision@5": 0.6,
+ "hit@5": 1.0,
+ "recall@10": 0.96875,
+ "precision@10": 0.434375,
+ "hit@10": 1.0
+ },
+ "single": {
+ "n": 21,
+ "mrr": 0.861111111111111,
+ "recall@1": 0.7619047619047619,
+ "precision@1": 0.7619047619047619,
+ "hit@1": 0.7619047619047619,
+ "recall@3": 0.9523809523809523,
+ "precision@3": 0.5714285714285714,
+ "hit@3": 0.9523809523809523,
+ "recall@5": 1.0,
+ "precision@5": 0.5238095238095238,
+ "hit@5": 1.0,
+ "recall@10": 1.0,
+ "precision@10": 0.3523809523809524,
+ "hit@10": 1.0
+ },
+ "multi": {
+ "n": 11,
+ "mrr": 0.9272727272727272,
+ "recall@1": 0.3939393939393939,
+ "precision@1": 0.9090909090909091,
+ "hit@1": 0.9090909090909091,
+ "recall@3": 0.712121212121212,
+ "precision@3": 0.7878787878787878,
+ "hit@3": 0.9090909090909091,
+ "recall@5": 0.8333333333333333,
+ "precision@5": 0.7454545454545454,
+ "hit@5": 1.0,
+ "recall@10": 0.9090909090909091,
+ "precision@10": 0.5909090909090909,
+ "hit@10": 1.0
+ }
+ },
+ "+hybrid": {
+ "all": {
+ "n": 32,
+ "mrr": 0.9296875,
+ "recall@1": 0.6822916666666666,
+ "precision@1": 0.875,
+ "hit@1": 0.875,
+ "recall@3": 0.8802083333333334,
+ "precision@3": 0.7083333333333333,
+ "hit@3": 0.96875,
+ "recall@5": 0.9479166666666666,
+ "precision@5": 0.64375,
+ "hit@5": 1.0,
+ "recall@10": 0.984375,
+ "precision@10": 0.484375,
+ "hit@10": 1.0
+ },
+ "single": {
+ "n": 21,
+ "mrr": 0.8928571428571429,
+ "recall@1": 0.8095238095238095,
+ "precision@1": 0.8095238095238095,
+ "hit@1": 0.8095238095238095,
+ "recall@3": 0.9523809523809523,
+ "precision@3": 0.6190476190476191,
+ "hit@3": 0.9523809523809523,
+ "recall@5": 1.0,
+ "precision@5": 0.5523809523809524,
+ "hit@5": 1.0,
+ "recall@10": 1.0,
+ "precision@10": 0.3857142857142857,
+ "hit@10": 1.0
+ },
+ "multi": {
+ "n": 11,
+ "mrr": 1.0,
+ "recall@1": 0.4393939393939394,
+ "precision@1": 1.0,
+ "hit@1": 1.0,
+ "recall@3": 0.7424242424242423,
+ "precision@3": 0.8787878787878788,
+ "hit@3": 1.0,
+ "recall@5": 0.8484848484848485,
+ "precision@5": 0.8181818181818182,
+ "hit@5": 1.0,
+ "recall@10": 0.9545454545454546,
+ "precision@10": 0.6727272727272727,
+ "hit@10": 1.0
+ }
+ },
+ "+rerank": {
+ "all": {
+ "n": 32,
+ "mrr": 0.984375,
+ "recall@1": 0.7760416666666666,
+ "precision@1": 0.96875,
+ "hit@1": 0.96875,
+ "recall@3": 0.9114583333333334,
+ "precision@3": 0.8125,
+ "hit@3": 1.0,
+ "recall@5": 0.9479166666666666,
+ "precision@5": 0.71875,
+ "hit@5": 1.0,
+ "recall@10": 0.984375,
+ "precision@10": 0.525,
+ "hit@10": 1.0
+ },
+ "single": {
+ "n": 21,
+ "mrr": 0.9761904761904762,
+ "recall@1": 0.9523809523809523,
+ "precision@1": 0.9523809523809523,
+ "hit@1": 0.9523809523809523,
+ "recall@3": 1.0,
+ "precision@3": 0.746031746031746,
+ "hit@3": 1.0,
+ "recall@5": 1.0,
+ "precision@5": 0.6190476190476191,
+ "hit@5": 1.0,
+ "recall@10": 1.0,
+ "precision@10": 0.40476190476190477,
+ "hit@10": 1.0
+ },
+ "multi": {
+ "n": 11,
+ "mrr": 1.0,
+ "recall@1": 0.4393939393939394,
+ "precision@1": 1.0,
+ "hit@1": 1.0,
+ "recall@3": 0.7424242424242423,
+ "precision@3": 0.9393939393939394,
+ "hit@3": 1.0,
+ "recall@5": 0.8484848484848485,
+ "precision@5": 0.9090909090909091,
+ "hit@5": 1.0,
+ "recall@10": 0.9545454545454546,
+ "precision@10": 0.7545454545454546,
+ "hit@10": 1.0
+ }
+ }
+}
\ No newline at end of file
diff --git a/assignments/pykido/week4/eval/retrieval_eval.py b/assignments/pykido/week4/eval/retrieval_eval.py
new file mode 100644
index 0000000..ea77937
--- /dev/null
+++ b/assignments/pykido/week4/eval/retrieval_eval.py
@@ -0,0 +1,73 @@
+import json
+import os
+import sys
+
+from dotenv import find_dotenv, load_dotenv
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, _HERE)
+sys.path.insert(0, os.path.dirname(_HERE))
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+
+from metrics import evaluate_query, mean_metrics
+
+from rag.retriever import retrieve
+
+DATASET = os.path.join(_HERE, "dataset.json")
+KS = (1, 3, 5, 10)
+TOP_K = 10
+FIRST_STAGE_N = 50
+
+MODES = {
+ "vector": {"mode": "vector"},
+ "+hybrid": {"mode": "hybrid"},
+ "+rerank": {"mode": "rerank", "first_stage_n": FIRST_STAGE_N},
+}
+
+
+def load_dataset(path: str = DATASET) -> list[dict]:
+ return json.load(open(path, encoding="utf-8"))["items"]
+
+
+def retrieved_sources(query: str, mode_kwargs: dict, k: int) -> list[str]:
+ return [doc.metadata.get("source") for doc in retrieve(query, k=k, **mode_kwargs)]
+
+
+def run_retrieval_eval(ks: tuple[int, ...] = KS, top_k: int = TOP_K) -> dict:
+ items = [it for it in load_dataset() if it.get("answerable", True) and it["relevant"]]
+ result: dict[str, dict] = {}
+ for mode_name, mode_kwargs in MODES.items():
+ per_query = []
+ for it in items:
+ sources = retrieved_sources(it["question"], mode_kwargs, top_k)
+ row = evaluate_query(sources, set(it["relevant"]), ks)
+ row["_hop"] = it["hop"]
+ per_query.append(row)
+ result[mode_name] = _aggregate_by_hop(per_query)
+ return result
+
+
+def _aggregate_by_hop(per_query: list[dict]) -> dict:
+ groups = {"all": per_query}
+ for hop in ("single", "multi"):
+ groups[hop] = [r for r in per_query if r["_hop"] == hop]
+ out = {}
+ for name, rows in groups.items():
+ clean = [{k: v for k, v in r.items() if k != "_hop"} for r in rows]
+ out[name] = {"n": len(clean), **mean_metrics(clean)}
+ return out
+
+
+def to_dataframe(result: dict, hop: str = "all"):
+ import pandas as pd
+
+ rows = {}
+ for mode_name, by_hop in result.items():
+ rows[mode_name] = {k: round(v, 3) for k, v in by_hop[hop].items() if k != "n"}
+ return pd.DataFrame(rows).T
+
+
+if __name__ == "__main__":
+ print(json.dumps(run_retrieval_eval(), ensure_ascii=False, indent=2))
diff --git a/assignments/pykido/week4/eval/run_eval.py b/assignments/pykido/week4/eval/run_eval.py
new file mode 100644
index 0000000..6730c89
--- /dev/null
+++ b/assignments/pykido/week4/eval/run_eval.py
@@ -0,0 +1,76 @@
+import json
+import os
+import sys
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, _HERE)
+sys.path.insert(0, os.path.dirname(_HERE))
+
+
+def _fmt_retrieval(res: dict) -> str:
+ lines = []
+ metrics = ["recall@1", "recall@3", "recall@5", "precision@5", "mrr", "hit@1", "hit@3"]
+ for hop in ("all", "single", "multi"):
+ lines.append(f"\n[{hop}]")
+ header = " " + f"{'mode':9s} " + " ".join(f"{m:>11s}" for m in metrics)
+ lines.append(header)
+ for mode, by_hop in res.items():
+ m = by_hop[hop]
+ row = " " + f"{mode:9s} " + " ".join(f"{m.get(k, 0):11.3f}" for k in metrics)
+ lines.append(row)
+ return "\n".join(lines)
+
+
+def _fmt_answer(summary: dict) -> str:
+ lines = []
+ for hop in ("all", "single", "multi", "none"):
+ lines.append(f"\n[{hop}]")
+ lines.append(f" {'system':9s} {'n':>3s} {'correctness':>12s} {'groundedness':>13s}")
+ for sys_name, by_hop in summary.items():
+ if hop not in by_hop:
+ continue
+ m = by_hop[hop]
+ lines.append(
+ f" {sys_name:9s} {m['n']:>3d} {m['correctness']:>12.2f} {m['groundedness']:>13.2f}"
+ )
+ return "\n".join(lines)
+
+
+def run_retrieval():
+ from retrieval_eval import run_retrieval_eval
+
+ print("=== Harness 1: retrieval_eval (vector / +hybrid / +rerank) ===")
+ res = run_retrieval_eval()
+ out = os.path.join(_HERE, "results_retrieval.json")
+ json.dump(res, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+ print(_fmt_retrieval(res))
+ print(f"\nsaved -> {out}")
+ return res
+
+
+def run_answer():
+ from answer_eval import run_answer_eval
+
+ print("\n=== Harness 2: answer_eval (baseline vs agentic, LLM-as-Judge) ===")
+ res = run_answer_eval()
+ out = os.path.join(_HERE, "results_answer.json")
+ json.dump(res, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+ print(_fmt_answer(res["summary"]))
+ print("\n--- baseline -> agentic 점수 변화 (문항별) ---")
+ for r in res["records"]:
+ b, a = r["baseline"], r["agentic"]
+ print(
+ f" [{r['id']}|{r['hop']:6s}|{r['failure_mode']:14s}] "
+ f"correct {b['correctness']}->{a['correctness']} "
+ f"ground {b['groundedness']}->{a['groundedness']}"
+ )
+ print(f"\nsaved -> {out}")
+ return res
+
+
+if __name__ == "__main__":
+ which = sys.argv[1] if len(sys.argv) > 1 else "both"
+ if which in ("both", "retrieval"):
+ run_retrieval()
+ if which in ("both", "answer"):
+ run_answer()
diff --git a/assignments/pykido/week4/eval/test_metrics.py b/assignments/pykido/week4/eval/test_metrics.py
new file mode 100644
index 0000000..b5c93cf
--- /dev/null
+++ b/assignments/pykido/week4/eval/test_metrics.py
@@ -0,0 +1,61 @@
+from metrics import hit_rate_at_k, precision_at_k, recall_at_k, reciprocal_rank
+
+
+def test_recall_collapses_topk_to_source_file_set():
+ retrieved = ["A", "A", "B", "C"]
+ relevant = {"A", "B"}
+ assert recall_at_k(retrieved, relevant, 3) == 1.0
+ assert recall_at_k(retrieved, relevant, 1) == 0.5
+
+
+def test_recall_multihop_partial():
+ assert recall_at_k(["A", "X", "Y"], {"A", "B"}, 3) == 0.5
+
+
+def test_precision_counts_chunks_not_fileset():
+ retrieved = ["A", "A", "B", "B", "C", "D", "E", "F", "G", "H"]
+ assert precision_at_k(retrieved, {"A", "B"}, 10) == 0.4
+
+
+def test_precision_at_1():
+ assert precision_at_k(["A", "B"], {"A"}, 1) == 1.0
+ assert precision_at_k(["C", "A"], {"A"}, 1) == 0.0
+
+
+def test_precision_denominator_min_k_len():
+ assert precision_at_k(["A"], {"A"}, 5) == 1.0
+
+
+def test_reciprocal_rank():
+ assert reciprocal_rank(["X", "A", "B"], {"A"}) == 0.5
+ assert reciprocal_rank(["A"], {"A"}) == 1.0
+ assert reciprocal_rank(["X", "Y"], {"A"}) == 0.0
+
+
+def test_hit_rate():
+ assert hit_rate_at_k(["X", "A"], {"A"}, 2) == 1.0
+ assert hit_rate_at_k(["X", "A"], {"A"}, 1) == 0.0
+ assert hit_rate_at_k(["X", "Y"], {"A"}, 5) == 0.0
+
+
+def test_empty_retrieved():
+ assert recall_at_k([], {"A"}, 5) == 0.0
+ assert precision_at_k([], {"A"}, 5) == 0.0
+ assert reciprocal_rank([], {"A"}) == 0.0
+ assert hit_rate_at_k([], {"A"}, 5) == 0.0
+
+
+if __name__ == "__main__":
+ import sys
+
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ failed = 0
+ for fn in fns:
+ try:
+ fn()
+ print(f"PASS {fn.__name__}")
+ except AssertionError as e:
+ failed += 1
+ print(f"FAIL {fn.__name__}: {e}")
+ print(f"\n{len(fns) - failed}/{len(fns)} passed")
+ sys.exit(1 if failed else 0)
diff --git a/assignments/pykido/week4/graph_agentic.py b/assignments/pykido/week4/graph_agentic.py
new file mode 100644
index 0000000..9a679e1
--- /dev/null
+++ b/assignments/pykido/week4/graph_agentic.py
@@ -0,0 +1,194 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+from langchain_core.documents import Document
+from langchain_core.messages import HumanMessage, SystemMessage
+from langchain_openai import ChatOpenAI
+from langgraph.graph import END, START, StateGraph
+
+from rag.retriever import retrieve
+from schema import DocGrade, HallucinationGrade, RAGAnswer
+from state import AgenticRAGState
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+os.environ["LANGSMITH_TRACING_V2"] = "false"
+
+DEFAULT_STRATEGY = "markdown"
+RETRIEVE_MODE = "rerank"
+FIRST_STAGE_N = 50
+TOP_K = 5
+MAX_REWRITE = 2
+MAX_REGEN = 2
+
+GENERATE_SYSTEM = """당신은 알고리즘 코딩 테스트를 돕는 학습 코치입니다.
+아래 검색된 문서(context)만 근거로 한국어 마크다운으로 답하세요.
+
+원칙:
+1. context 에 있는 내용만 사용하고, 없으면 모른다고 말할 것
+2. 패턴 이름·복잡도·코드는 context 에 적힌 표현을 따를 것
+3. 답변에 어떤 패턴/문제 근거를 썼는지 자연스럽게 녹일 것
+4. 패턴 키와 코드 식별자는 영어 원형 유지
+"""
+
+GRADE_DOCS_SYSTEM = """당신은 검색 품질 평가자입니다.
+검색된 문서 묶음이 사용자 질문에 답할 근거를 담고 있는지 binary 로 판정하세요.
+관련 키워드나 의미가 일부라도 답에 쓸 수 있으면 'yes', 전혀 무관하면 'no'.
+지나치게 엄격하지 말고, 답의 근거가 될 수 있으면 yes 로 판정합니다."""
+
+HALLUCINATION_SYSTEM = """당신은 환각 검사자입니다.
+생성된 답변의 핵심 주장이 모두 검색된 문서(context)로 뒷받침되는지 binary 로 판정하세요.
+context 에 없는 사실을 지어냈으면 'no'(환각), 전부 근거가 있으면 'yes'.
+답변이 '근거가 없어 모른다'고 정직하게 말한 경우는 환각이 아니므로 'yes' 로 봅니다."""
+
+REWRITE_SYSTEM = """당신은 검색 질의 재작성기입니다.
+원래 질문의 의도는 유지하되, 알고리즘 학습 문서 검색이 잘 되도록
+핵심 개념어·자료구조·기법 이름을 드러내 한국어 한 문장으로 다시 쓰세요.
+질문만 출력하고 다른 설명은 붙이지 마세요."""
+
+model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
+gen_model = model.with_structured_output(RAGAnswer)
+doc_grader = model.with_structured_output(DocGrade)
+hallucination_grader = model.with_structured_output(HallucinationGrade)
+
+
+def format_docs(docs: list[Document]) -> str:
+ blocks = []
+ for i, doc in enumerate(docs, start=1):
+ blocks.append(
+ f"[문서 {i}] source={doc.metadata.get('source')} "
+ f"chunk={doc.metadata.get('chunk_id')}\n{doc.page_content}"
+ )
+ return "\n\n".join(blocks)
+
+
+def collect_sources(docs: list[Document]) -> list[str]:
+ seen, sources = set(), []
+ for doc in docs:
+ source = doc.metadata.get("source")
+ if source and source not in seen:
+ seen.add(source)
+ sources.append(source)
+ return sources
+
+
+def retrieve_node(state: AgenticRAGState) -> dict:
+ strategy = state.get("strategy") or DEFAULT_STRATEGY
+ query = state.get("query") or state["question"]
+ docs = retrieve(
+ query,
+ mode=RETRIEVE_MODE,
+ strategy=strategy,
+ k=TOP_K,
+ first_stage_n=FIRST_STAGE_N,
+ )
+ return {"strategy": strategy, "query": query, "documents": docs, "context": format_docs(docs)}
+
+
+def grade_docs_node(state: AgenticRAGState) -> dict:
+ messages = [
+ SystemMessage(content=GRADE_DOCS_SYSTEM),
+ HumanMessage(content=f"질문: {state['question']}\n\n검색된 문서:\n{state['context']}"),
+ ]
+ grade = doc_grader.invoke(messages)
+ return {"doc_grade": grade.relevant}
+
+
+def rewrite_node(state: AgenticRAGState) -> dict:
+ base = state.get("query") or state["question"]
+ messages = [
+ SystemMessage(content=REWRITE_SYSTEM),
+ HumanMessage(content=f"원래 질문: {state['question']}\n직전 질의: {base}"),
+ ]
+ rewritten = model.invoke(messages).content.strip()
+ return {"query": rewritten, "rewrite_count": state.get("rewrite_count", 0) + 1}
+
+
+def _generate(state: AgenticRAGState, feedback: str | None = None) -> dict:
+ human = f"질문: {state['question']}\n\ncontext:\n{state['context']}"
+ if feedback:
+ human += f"\n\n{feedback}"
+ messages = [SystemMessage(content=GENERATE_SYSTEM), HumanMessage(content=human)]
+ answer = gen_model.invoke(messages)
+ final = answer.model_dump()
+ final["sources"] = collect_sources(state["documents"])
+ return {"final_answer": final}
+
+
+def generate_node(state: AgenticRAGState) -> dict:
+ return _generate(state)
+
+
+def check_hallucination_node(state: AgenticRAGState) -> dict:
+ answer_text = (state.get("final_answer") or {}).get("answer", "")
+ messages = [
+ SystemMessage(content=HALLUCINATION_SYSTEM),
+ HumanMessage(content=f"context:\n{state['context']}\n\n생성된 답변:\n{answer_text}"),
+ ]
+ grade = hallucination_grader.invoke(messages)
+ return {"hallucination_grade": grade.grounded}
+
+
+def regenerate_node(state: AgenticRAGState) -> dict:
+ prior = (state.get("final_answer") or {}).get("answer", "")
+ feedback = (
+ "이전 답변에 context 로 뒷받침되지 않는 내용이 있었습니다.\n"
+ f"이전 답변: {prior}\n"
+ "context 에 실제로 적힌 내용만으로 다시 작성하고, 근거가 없는 부분은 '모른다'고 하세요."
+ )
+ out = _generate(state, feedback=feedback)
+ out["regen_count"] = state.get("regen_count", 0) + 1
+ return out
+
+
+def route_after_grade(state: AgenticRAGState) -> str:
+ if state.get("doc_grade") == "yes":
+ return "generate"
+ if state.get("rewrite_count", 0) < MAX_REWRITE:
+ return "rewrite"
+ return "generate"
+
+
+def route_after_hallucination(state: AgenticRAGState) -> str:
+ if state.get("hallucination_grade") == "yes":
+ return END
+ if state.get("regen_count", 0) < MAX_REGEN:
+ return "regenerate"
+ return END
+
+
+def build_graph():
+ builder = StateGraph(AgenticRAGState)
+ builder.add_node("retrieve", retrieve_node)
+ builder.add_node("grade_docs", grade_docs_node)
+ builder.add_node("rewrite", rewrite_node)
+ builder.add_node("generate", generate_node)
+ builder.add_node("regenerate", regenerate_node)
+ builder.add_node("check_hallucination", check_hallucination_node)
+
+ builder.add_edge(START, "retrieve")
+ builder.add_edge("retrieve", "grade_docs")
+ builder.add_conditional_edges(
+ "grade_docs",
+ route_after_grade,
+ {"generate": "generate", "rewrite": "rewrite"},
+ )
+ builder.add_edge("rewrite", "retrieve")
+ builder.add_edge("generate", "check_hallucination")
+ builder.add_conditional_edges(
+ "check_hallucination",
+ route_after_hallucination,
+ {"regenerate": "regenerate", END: END},
+ )
+ builder.add_edge("regenerate", "check_hallucination")
+ return builder.compile()
+
+
+graph = build_graph()
+
+
+def ask(question: str, strategy: str = DEFAULT_STRATEGY) -> dict:
+ result = graph.invoke(
+ {"question": question, "strategy": strategy, "rewrite_count": 0, "regen_count": 0}
+ )
+ return result["final_answer"]
diff --git a/assignments/pykido/week4/graph_baseline.py b/assignments/pykido/week4/graph_baseline.py
new file mode 100644
index 0000000..56c5a36
--- /dev/null
+++ b/assignments/pykido/week4/graph_baseline.py
@@ -0,0 +1,86 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+from langchain_core.documents import Document
+from langchain_core.messages import HumanMessage, SystemMessage
+from langchain_openai import ChatOpenAI
+from langgraph.graph import END, START, StateGraph
+
+from rag.retriever import retrieve
+from schema import RAGAnswer
+from state import RAGState
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+os.environ["LANGSMITH_TRACING_V2"] = "false"
+
+SYSTEM_PROMPT = """당신은 알고리즘 코딩 테스트를 돕는 학습 코치입니다.
+아래 검색된 문서(context)만 근거로 한국어 마크다운으로 답하세요.
+
+원칙:
+1. context 에 있는 내용만 사용하고, 없으면 모른다고 말할 것
+2. 패턴 이름·복잡도·코드는 context 에 적힌 표현을 따를 것
+3. 답변에 어떤 패턴/문제 근거를 썼는지 자연스럽게 녹일 것
+4. 패턴 키와 코드 식별자는 영어 원형 유지
+"""
+
+DEFAULT_STRATEGY = "markdown"
+TOP_K = 4
+
+model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
+structured_model = model.with_structured_output(RAGAnswer)
+
+
+def format_docs(docs: list[Document]) -> str:
+ blocks = []
+ for i, doc in enumerate(docs, start=1):
+ blocks.append(
+ f"[문서 {i}] source={doc.metadata.get('source')} "
+ f"chunk={doc.metadata.get('chunk_id')}\n{doc.page_content}"
+ )
+ return "\n\n".join(blocks)
+
+
+def collect_sources(docs: list[Document]) -> list[str]:
+ seen, sources = set(), []
+ for doc in docs:
+ source = doc.metadata.get("source")
+ if source and source not in seen:
+ seen.add(source)
+ sources.append(source)
+ return sources
+
+
+def retrieve_node(state: RAGState) -> dict:
+ strategy = state.get("strategy") or DEFAULT_STRATEGY
+ docs = retrieve(state["question"], mode="vector", strategy=strategy, k=TOP_K)
+ return {"strategy": strategy, "documents": docs, "context": format_docs(docs)}
+
+
+def generate_node(state: RAGState) -> dict:
+ messages = [
+ SystemMessage(content=SYSTEM_PROMPT),
+ HumanMessage(content=f"질문: {state['question']}\n\ncontext:\n{state['context']}"),
+ ]
+ answer = structured_model.invoke(messages)
+ final = answer.model_dump()
+ final["sources"] = collect_sources(state["documents"])
+ return {"final_answer": final}
+
+
+def build_graph():
+ builder = StateGraph(RAGState)
+ builder.add_node("retrieve", retrieve_node)
+ builder.add_node("generate", generate_node)
+ builder.add_edge(START, "retrieve")
+ builder.add_edge("retrieve", "generate")
+ builder.add_edge("generate", END)
+ return builder.compile()
+
+
+graph = build_graph()
+
+
+def ask(question: str, strategy: str = DEFAULT_STRATEGY) -> dict:
+ result = graph.invoke({"question": question, "strategy": strategy})
+ return result["final_answer"]
diff --git a/assignments/pykido/week4/parsing/parse_pdf.py b/assignments/pykido/week4/parsing/parse_pdf.py
new file mode 100644
index 0000000..5c24ab9
--- /dev/null
+++ b/assignments/pykido/week4/parsing/parse_pdf.py
@@ -0,0 +1,37 @@
+import os
+import sys
+from pathlib import Path
+
+from dotenv import find_dotenv, load_dotenv
+
+load_dotenv(find_dotenv(), override=True)
+
+WEEK4_DIR = Path(__file__).resolve().parent.parent
+PARSED_DIR = WEEK4_DIR / "data" / "parsed"
+
+
+def parse_pdf(pdf_path: str, output_stem: str | None = None) -> Path:
+ from llama_parse import LlamaParse
+
+ api_key = os.environ.get("LLAMA_CLOUD_API_KEY")
+ if not api_key:
+ raise RuntimeError("LLAMA_CLOUD_API_KEY 가 .env 에 없습니다. LlamaParse 키를 발급하세요.")
+
+ pdf = Path(pdf_path)
+ stem = output_stem or pdf.stem
+ parser = LlamaParse(api_key=api_key, result_type="markdown", language="ko")
+ documents = parser.load_data(str(pdf))
+ markdown = "\n\n".join(doc.text for doc in documents)
+
+ PARSED_DIR.mkdir(parents=True, exist_ok=True)
+ out_path = PARSED_DIR / f"{stem}.md"
+ out_path.write_text(markdown, encoding="utf-8")
+ print(f"parsed {pdf.name} -> {out_path.relative_to(WEEK4_DIR)} ({len(markdown)} chars)")
+ return out_path
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("usage: python parsing/parse_pdf.py [output_stem]")
+ sys.exit(1)
+ parse_pdf(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
diff --git a/assignments/pykido/week4/rag/__init__.py b/assignments/pykido/week4/rag/__init__.py
new file mode 100644
index 0000000..50b3134
--- /dev/null
+++ b/assignments/pykido/week4/rag/__init__.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+WEEK4_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = WEEK4_DIR / "data"
+INDEX_DIR = WEEK4_DIR / ".cache"
+
+STRATEGIES = ("recursive", "markdown")
diff --git a/assignments/pykido/week4/rag/hybrid.py b/assignments/pykido/week4/rag/hybrid.py
new file mode 100644
index 0000000..f558282
--- /dev/null
+++ b/assignments/pykido/week4/rag/hybrid.py
@@ -0,0 +1,65 @@
+import re
+from functools import lru_cache
+
+from langchain_core.documents import Document
+from rank_bm25 import BM25Okapi
+
+from .loader import load_documents
+from .splitter import split_documents
+from .vectorstore import load_vectorstore
+
+RRF_K = 60
+
+
+def _tokenize(text: str) -> list[str]:
+ return re.findall(r"[가-힣]+|[a-zA-Z0-9]+", text.lower())
+
+
+@lru_cache(maxsize=None)
+def _chunks(strategy: str) -> tuple[Document, ...]:
+ return tuple(split_documents(load_documents(), strategy))
+
+
+@lru_cache(maxsize=None)
+def _bm25(strategy: str) -> BM25Okapi:
+ return BM25Okapi([_tokenize(c.page_content) for c in _chunks(strategy)])
+
+
+@lru_cache(maxsize=None)
+def _store(strategy: str):
+ return load_vectorstore(strategy)
+
+
+@lru_cache(maxsize=None)
+def vector_search(query: str, strategy: str, top_n: int) -> list[Document]:
+ return _store(strategy).as_retriever(search_kwargs={"k": top_n}).invoke(query)
+
+
+def bm25_search(query: str, strategy: str, top_n: int) -> list[Document]:
+ chunks = _chunks(strategy)
+ scores = _bm25(strategy).get_scores(_tokenize(query))
+ ranked = sorted(range(len(chunks)), key=lambda i: scores[i], reverse=True)
+ return [chunks[i] for i in ranked[:top_n]]
+
+
+def rrf_fuse(rankings: list[list[Document]], top_n: int, k: int = RRF_K) -> list[Document]:
+ scores: dict[str, float] = {}
+ by_id: dict[str, Document] = {}
+ for ranking in rankings:
+ for rank, doc in enumerate(ranking, start=1):
+ cid = doc.metadata.get("chunk_id") or doc.page_content
+ scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
+ by_id.setdefault(cid, doc)
+ fused = sorted(scores, key=lambda cid: scores[cid], reverse=True)
+ return [by_id[cid] for cid in fused[:top_n]]
+
+
+def hybrid_search(
+ query: str,
+ strategy: str = "markdown",
+ top_n: int = 50,
+ rrf_k: int = RRF_K,
+) -> list[Document]:
+ vector = vector_search(query, strategy, top_n)
+ keyword = bm25_search(query, strategy, top_n)
+ return rrf_fuse([vector, keyword], top_n=top_n, k=rrf_k)
diff --git a/assignments/pykido/week4/rag/indexing.py b/assignments/pykido/week4/rag/indexing.py
new file mode 100644
index 0000000..5b19874
--- /dev/null
+++ b/assignments/pykido/week4/rag/indexing.py
@@ -0,0 +1,31 @@
+import os
+
+from dotenv import find_dotenv, load_dotenv
+
+from . import STRATEGIES
+from .loader import load_documents
+
+load_dotenv(find_dotenv(), override=True)
+os.environ["LANGSMITH_TRACING"] = "false"
+from .splitter import split_documents
+from .vectorstore import build_vectorstore, save_vectorstore
+
+
+def build_index(strategy: str) -> int:
+ docs = load_documents()
+ chunks = split_documents(docs, strategy)
+ store = build_vectorstore(chunks)
+ save_vectorstore(store, strategy)
+ return len(chunks)
+
+
+def build_all() -> dict[str, int]:
+ counts = {}
+ for strategy in STRATEGIES:
+ counts[strategy] = build_index(strategy)
+ print(f"[{strategy}] indexed {counts[strategy]} chunks")
+ return counts
+
+
+if __name__ == "__main__":
+ build_all()
diff --git a/assignments/pykido/week4/rag/loader.py b/assignments/pykido/week4/rag/loader.py
new file mode 100644
index 0000000..7c387dd
--- /dev/null
+++ b/assignments/pykido/week4/rag/loader.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+from langchain_community.document_loaders import TextLoader
+from langchain_core.documents import Document
+
+from . import DATA_DIR
+
+
+def load_documents(data_dir: Path = DATA_DIR) -> list[Document]:
+ docs: list[Document] = []
+ for path in sorted(data_dir.rglob("*.md")):
+ loaded = TextLoader(str(path), encoding="utf-8").load()
+ category = path.parent.name
+ for doc in loaded:
+ doc.metadata.update(
+ {
+ "source": str(path.relative_to(data_dir)),
+ "filename": path.name,
+ "stem": path.stem,
+ "category": category,
+ }
+ )
+ docs.append(doc)
+ return docs
+
+
+if __name__ == "__main__":
+ documents = load_documents()
+ print(f"loaded {len(documents)} documents")
+ for doc in documents[:3]:
+ print("-" * 60)
+ print(doc.metadata)
+ print(doc.page_content[:200])
diff --git a/assignments/pykido/week4/rag/reranker.py b/assignments/pykido/week4/rag/reranker.py
new file mode 100644
index 0000000..d8ed1c9
--- /dev/null
+++ b/assignments/pykido/week4/rag/reranker.py
@@ -0,0 +1,55 @@
+import os
+from functools import lru_cache
+
+from langchain_core.documents import Document
+
+LOCAL_MODEL = "BAAI/bge-reranker-v2-m3"
+COHERE_MODEL = "rerank-multilingual-v3.0"
+
+
+@lru_cache(maxsize=None)
+def _local_model(model_name: str):
+ from sentence_transformers import CrossEncoder
+
+ return CrossEncoder(model_name)
+
+
+def _rerank_local(query: str, docs: list[Document], top_k: int, model_name: str) -> list[Document]:
+ if not docs:
+ return []
+ scores = _local_model(model_name).predict([(query, doc.page_content) for doc in docs])
+ ranked = sorted(zip(docs, scores), key=lambda pair: pair[1], reverse=True)
+ return [doc for doc, _ in ranked[:top_k]]
+
+
+@lru_cache(maxsize=None)
+def _cohere_client():
+ import cohere
+
+ return cohere.Client(os.environ["COHERE_API_KEY"])
+
+
+def _rerank_cohere(query: str, docs: list[Document], top_k: int, model_name: str) -> list[Document]:
+ if not docs:
+ return []
+ result = _cohere_client().rerank(
+ model=model_name,
+ query=query,
+ documents=[doc.page_content for doc in docs],
+ top_n=top_k,
+ )
+ return [docs[r.index] for r in result.results]
+
+
+def rerank(
+ query: str,
+ docs: list[Document],
+ top_k: int = 5,
+ backend: str = "local",
+ model_name: str | None = None,
+) -> list[Document]:
+ if backend == "local":
+ return _rerank_local(query, docs, top_k, model_name or LOCAL_MODEL)
+ if backend == "cohere":
+ return _rerank_cohere(query, docs, top_k, model_name or COHERE_MODEL)
+ raise ValueError(f"unknown rerank backend: {backend}")
diff --git a/assignments/pykido/week4/rag/retriever.py b/assignments/pykido/week4/rag/retriever.py
new file mode 100644
index 0000000..5b814e7
--- /dev/null
+++ b/assignments/pykido/week4/rag/retriever.py
@@ -0,0 +1,24 @@
+from langchain_core.documents import Document
+
+from .hybrid import hybrid_search, vector_search
+from .reranker import rerank
+
+DEFAULT_STRATEGY = "markdown"
+
+
+def retrieve(
+ query: str,
+ mode: str = "vector",
+ strategy: str = DEFAULT_STRATEGY,
+ k: int = 4,
+ first_stage_n: int = 50,
+ rerank_backend: str = "local",
+) -> list[Document]:
+ if mode == "vector":
+ return vector_search(query, strategy, k)
+ if mode == "hybrid":
+ return hybrid_search(query, strategy, top_n=k)
+ if mode == "rerank":
+ candidates = hybrid_search(query, strategy, top_n=first_stage_n)
+ return rerank(query, candidates, top_k=k, backend=rerank_backend)
+ raise ValueError(f"unknown retrieve mode: {mode}")
diff --git a/assignments/pykido/week4/rag/splitter.py b/assignments/pykido/week4/rag/splitter.py
new file mode 100644
index 0000000..7ed96e7
--- /dev/null
+++ b/assignments/pykido/week4/rag/splitter.py
@@ -0,0 +1,53 @@
+from langchain_core.documents import Document
+from langchain_text_splitters import (
+ MarkdownHeaderTextSplitter,
+ RecursiveCharacterTextSplitter,
+)
+
+HEADERS = [("#", "h1"), ("##", "h2"), ("###", "h3")]
+
+
+def split_recursive(
+ docs: list[Document],
+ chunk_size: int = 700,
+ chunk_overlap: int = 100,
+) -> list[Document]:
+ splitter = RecursiveCharacterTextSplitter(
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ separators=["\n\n", "\n", " ", ""],
+ )
+ chunks = splitter.split_documents(docs)
+ for i, chunk in enumerate(chunks):
+ chunk.metadata["chunk_strategy"] = "recursive"
+ chunk.metadata["chunk_id"] = f"{chunk.metadata.get('stem', 'doc')}-rec-{i}"
+ return chunks
+
+
+def split_markdown(docs: list[Document]) -> list[Document]:
+ splitter = MarkdownHeaderTextSplitter(headers_to_split_on=HEADERS, strip_headers=False)
+ chunks: list[Document] = []
+ for doc in docs:
+ for i, section in enumerate(splitter.split_text(doc.page_content)):
+ section.metadata = {**doc.metadata, **section.metadata}
+ section.metadata["chunk_strategy"] = "markdown"
+ section.metadata["chunk_id"] = f"{doc.metadata.get('stem', 'doc')}-md-{i}"
+ chunks.append(section)
+ return chunks
+
+
+def split_documents(docs: list[Document], strategy: str) -> list[Document]:
+ if strategy == "recursive":
+ return split_recursive(docs)
+ if strategy == "markdown":
+ return split_markdown(docs)
+ raise ValueError(f"unknown strategy: {strategy}")
+
+
+if __name__ == "__main__":
+ from .loader import load_documents
+
+ docs = load_documents()
+ for strategy in ("recursive", "markdown"):
+ chunks = split_documents(docs, strategy)
+ print(f"[{strategy}] {len(docs)} docs -> {len(chunks)} chunks")
diff --git a/assignments/pykido/week4/rag/vectorstore.py b/assignments/pykido/week4/rag/vectorstore.py
new file mode 100644
index 0000000..fedea4b
--- /dev/null
+++ b/assignments/pykido/week4/rag/vectorstore.py
@@ -0,0 +1,36 @@
+import shutil
+
+from langchain_community.vectorstores import FAISS
+from langchain_core.documents import Document
+from langchain_openai import OpenAIEmbeddings
+
+from . import INDEX_DIR
+
+EMBEDDING_MODEL = "text-embedding-3-small"
+
+
+def get_embeddings() -> OpenAIEmbeddings:
+ return OpenAIEmbeddings(model=EMBEDDING_MODEL)
+
+
+def build_vectorstore(chunks: list[Document]) -> FAISS:
+ return FAISS.from_documents(chunks, get_embeddings())
+
+
+def save_vectorstore(store: FAISS, strategy: str) -> None:
+ path = INDEX_DIR / strategy
+ if path.exists():
+ shutil.rmtree(path)
+ path.mkdir(parents=True, exist_ok=True)
+ store.save_local(str(path))
+
+
+def load_vectorstore(strategy: str) -> FAISS:
+ path = INDEX_DIR / strategy
+ if not path.exists():
+ raise FileNotFoundError(f"index not found: {path}. run indexing.py first.")
+ return FAISS.load_local(
+ str(path),
+ get_embeddings(),
+ allow_dangerous_deserialization=True,
+ )
diff --git a/assignments/pykido/week4/run.ipynb b/assignments/pykido/week4/run.ipynb
new file mode 100644
index 0000000..c70220e
--- /dev/null
+++ b/assignments/pykido/week4/run.ipynb
@@ -0,0 +1,201 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Week 4 — Agentic RAG & Evaluation\n",
+ "\n",
+ "알고리즘 코딩 테스트 코칭 RAG 를 두 방향으로 발전시킨다.\n",
+ "\n",
+ "1. **검색·생성 튜닝** — Hybrid(BM25+RRF) · Reranker(cross-encoder) · LlamaParse · grade_docs+rewrite(CRAG 미니) · hallucination grader(Self-RAG 미니)\n",
+ "2. **정량/정성 평가** — retrieval_eval(4지표 × K 스윕) · answer_eval(LLM-as-Judge) · hop별 집계\n",
+ "\n",
+ "핵심 가치는 점수를 올리는 것이 아니라 **RAG 성능을 측정 가능하게 만들고 변화를 근거와 함께 설명하는 것**이다.\n",
+ "\n",
+ "> 실행 전 준비: `.env` 에 `OPENAI_API_KEY` (임베딩·생성·judge), 선택적으로 `LLAMA_CLOUD_API_KEY`(PDF 파싱), reranker 로컬 모델용 `torch`/`sentence-transformers` 설치. 인덱스가 없으면 `python -m rag.indexing` 으로 빌드한다(week3 인덱스를 `.cache/` 로 복사해 두면 재임베딩 비용 0)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os, sys, json\n",
+ "sys.path.insert(0, os.path.abspath(\".\"))\n",
+ "sys.path.insert(0, os.path.abspath(\"eval\"))\n",
+ "import pandas as pd\n",
+ "pd.set_option(\"display.precision\", 3)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 0. 인덱스 준비\n",
+ "\n",
+ "markdown / recursive 전략별 FAISS 인덱스를 빌드한다. 이미 `.cache/` 에 있으면 건너뛴다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from rag import INDEX_DIR\n",
+ "if not (INDEX_DIR / \"markdown\").exists():\n",
+ " from rag.indexing import build_all\n",
+ " build_all()\n",
+ "else:\n",
+ " print(\"index exists:\", [p.name for p in INDEX_DIR.iterdir()])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. 검색 모드 데모 — vector / hybrid / rerank\n",
+ "\n",
+ "같은 쿼리에 대해 세 모드가 어떤 문서를 어떤 순서로 가져오는지 비교한다. (`rerank` 는 `torch`/`sentence-transformers` 필요)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from rag.retriever import retrieve\n",
+ "q = \"입국심사 문제가 왜 parametric search 인가\"\n",
+ "for mode in (\"vector\", \"hybrid\", \"rerank\"):\n",
+ " print(f\"\\n[{mode}]\")\n",
+ " try:\n",
+ " for d in retrieve(q, mode=mode, k=5):\n",
+ " print(\" -\", d.metadata[\"source\"], \"|\", d.metadata[\"chunk_id\"])\n",
+ " except Exception as e:\n",
+ " print(\" (skipped:\", type(e).__name__, e, \")\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. Harness 1 — retrieval_eval (정량, 결정적)\n",
+ "\n",
+ "vector → +hybrid → +rerank 로 검색이 단계별로 얼마나 올라가는지 4지표 × K 스윕으로 분해한다. (LLM 없음)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from retrieval_eval import run_retrieval_eval, to_dataframe\n",
+ "ret = run_retrieval_eval()\n",
+ "print(\"=== 전체 (all) ===\")\n",
+ "display(to_dataframe(ret, \"all\"))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print(\"=== single-hop ===\"); display(to_dataframe(ret, \"single\"))\n",
+ "print(\"=== multi-hop ===\"); display(to_dataframe(ret, \"multi\"))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**해석 포인트** — md 문서가 깨끗해 vector-only Recall@K 가 이미 높을 수 있다(천장 효과). 그래서 순위 민감 지표(MRR, Hit@1)와 multi-hop 행에서 hybrid/rerank 의 차이를 본다."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. 그래프 데모 — baseline vs agentic\n",
+ "\n",
+ "같은 질문을 두 그래프로 실행해 최종 답변을 비교한다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import graph_baseline, graph_agentic\n",
+ "q = \"벨만-포드 알고리즘의 의사코드를 알려줘\" # 도메인 밖: '모른다'고 답해야 함\n",
+ "print(\"[baseline]\\n\", json.dumps(graph_baseline.ask(q), ensure_ascii=False, indent=2))\n",
+ "print(\"\\n[agentic]\\n\", json.dumps(graph_agentic.ask(q), ensure_ascii=False, indent=2))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. Harness 2 — answer_eval (정성, LLM-as-Judge)\n",
+ "\n",
+ "baseline vs agentic 의 최종 답변을 Correctness·Groundedness 로 채점한다. grade_docs·rewrite·hallucination grader 의 가치가 여기서 드러난다.\n",
+ "\n",
+ "> OpenAI API 호출 비용(gpt-4o-mini, 소액)이 발생한다. 처음에는 `limit` 으로 일부만 돌려 비용을 확인한다."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from answer_eval import run_answer_eval, to_dataframe as ans_df\n",
+ "ans = run_answer_eval() # 전체 36문항. 비용이 걱정되면 run_answer_eval(limit=6)\n",
+ "print(\"=== 전체 (all) ===\"); display(ans_df(ans[\"summary\"], \"all\"))\n",
+ "print(\"=== single-hop ===\"); display(ans_df(ans[\"summary\"], \"single\"))\n",
+ "print(\"=== multi-hop ===\"); display(ans_df(ans[\"summary\"], \"multi\"))\n",
+ "print(\"=== out-of-domain ===\"); display(ans_df(ans[\"summary\"], \"none\"))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 실패 모드별로 baseline 대비 agentic 이 무엇을 바꿨는지 개별 레코드로 본다.\n",
+ "for r in ans[\"records\"]:\n",
+ " b, a = r[\"baseline\"], r[\"agentic\"]\n",
+ " print(f\"[{r['id']}|{r['hop']}|{r['failure_mode']}] \"\n",
+ " f\"correct {b['correctness']}->{a['correctness']} \"\n",
+ " f\"ground {b['groundedness']}->{a['groundedness']}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. (선택) LlamaParse 로 PDF 편입\n",
+ "\n",
+ "`LLAMA_CLOUD_API_KEY` 가 있으면 PDF 1개를 markdown 으로 파싱해 `data/parsed/` 에 넣고 인덱스를 재빌드한다.\n",
+ "\n",
+ "```python\n",
+ "from parsing.parse_pdf import parse_pdf\n",
+ "parse_pdf(\"\")\n",
+ "from rag.indexing import build_all; build_all()\n",
+ "```"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
+ "language_info": {"name": "python", "version": "3.12"}
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/assignments/pykido/week4/schema.py b/assignments/pykido/week4/schema.py
new file mode 100644
index 0000000..6b4def9
--- /dev/null
+++ b/assignments/pykido/week4/schema.py
@@ -0,0 +1,38 @@
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class RAGAnswer(BaseModel):
+ answer: str = Field(description="한국어 마크다운 답변. context 근거만 사용하고 모르면 모른다고 한다.")
+ confidence: float = Field(
+ ge=0.0,
+ le=1.0,
+ description="context 가 질문을 직접 뒷받침하면 0.85+, 부분적 근거면 ≤0.7.",
+ )
+
+
+class DocGrade(BaseModel):
+ relevant: Literal["yes", "no"] = Field(
+ description="문서 묶음이 질문에 답할 근거를 담고 있으면 'yes', 아니면 'no'."
+ )
+
+
+class HallucinationGrade(BaseModel):
+ grounded: Literal["yes", "no"] = Field(
+ description="답의 모든 핵심 주장이 context 문서로 뒷받침되면 'yes', 환각이 있으면 'no'."
+ )
+
+
+class JudgeScore(BaseModel):
+ correctness: int = Field(
+ ge=1,
+ le=5,
+ description="답이 reference answer 와 의미적으로 일치하는 정도. 1=완전히 틀림, 5=완전히 일치.",
+ )
+ groundedness: int = Field(
+ ge=1,
+ le=5,
+ description="답이 검색된 context 문서에 근거하는 정도. 1=환각, 5=전부 근거.",
+ )
+ reason: str = Field(description="두 점수에 대한 한 줄 근거 (한국어).")
diff --git a/assignments/pykido/week4/state.py b/assignments/pykido/week4/state.py
new file mode 100644
index 0000000..de3e7ed
--- /dev/null
+++ b/assignments/pykido/week4/state.py
@@ -0,0 +1,24 @@
+from typing import Optional, TypedDict
+
+from langchain_core.documents import Document
+
+
+class RAGState(TypedDict):
+ question: str
+ strategy: str
+ documents: list[Document]
+ context: str
+ final_answer: Optional[dict]
+
+
+class AgenticRAGState(TypedDict, total=False):
+ question: str
+ query: str
+ strategy: str
+ documents: list[Document]
+ context: str
+ doc_grade: str
+ hallucination_grade: str
+ rewrite_count: int
+ regen_count: int
+ final_answer: Optional[dict]