Dynamic Programming
Dynamic Programming
- 중복 계산을 피하기 위해 중간 결과를 저장해두는 문제 해결 기법
- ‘모든 것을 다 하지 않는다’ → 효율적인 선택
- Memoization: 하위 문제의 결과를 저장
- Optimal Substructure: 전체 최적해에 포함된 부분 문제도 최적이라는 성질 (항상 성립하진 않음)
1. Divide and conquer
• Break down large problem into smaller subproblems • Solve the subproblems to combine the results
2. Memoization (not memorization!)
• Storing intermediary results into memory – results from subproblems • When forming the larger results, simply look-up the memory
예시
1. 피보나치 수열
재귀 방식은 비효율적 메모이제이션(또는 반복문)으로 중복 계산 방지 → 시간복잡도 감소
- Recall that fib(n) = fib(n – 1) + fib(n – 2)
- fib(n-1) and fib(n-2) have already been computed numerous times! → Memoization
- Record the intermediary results and re-use later → No need to re-do everything
1-1. 시간복잡도 (Time Complexity)
O(n) 0부터 n까지 한 번씩 계산하기 때문에 반복문은 총 n-1번 수행됨
각 반복은 O(1)이므로 전체 시간복잡도는 O(n)
1-2. 공간복잡도 (Space Complexity)
기본 방식: O(n) 결과 저장을 위해 n+1 크기의 배열 dp[] 사용
2. 최단 경로 문제
- Dijkstra’s Algorithm (단일 출발점)
- Floyd-Warshall Algorithm (모든 쌍 간 경로)
기본 개념
- 기본 아이디어 1: Optimal Substructure
“최단 경로의 일부 경로도 역시 최단 경로다.”
예: A → B → C가 최단 경로라면, A → B도 최단 경로여야 함.
이는 DP에서 필수적인 조건 중 하나 → Subpath(부분 경로) 가 최적이라는 것을 믿고, 이를 기반으로 더 큰 문제를 해결할 수 있음 - 기본 아이디어 2: Detour Might Be Better
“우회 경로가 더 짧으면 우회하라”
(≒ 역삼각 부등식, reverse triangle inequality)
Matrix Chain Multiplication
행렬 곱샘 최적화! → 여러 개의 행렬을 곱할 때, 곱셈 연산 횟수를 최소화하는 곱셈 순서를 찾는 것.
- 행렬 곱셈은 결합법칙만 성립, 교환법칙은 X
- 행렬 A: (n × m), B: (m × k) 라면
- 곱하는 데 드는 연산량은 n × m × k
테이블
- 행렬이 N개 있으면, N×N 크기의 2차원 테이블 m[i][j] 생성
- m[i][j]: Ai ~ Aj를 곱하는 데 드는 최소 연산 횟수
- i < j인 경우만 채운다 → 상삼각형만 사용
why 상삼각형?
- 행렬 곱셈 순서 문제의 핵심 조건
우리는 주어진 순서대로 행렬을 곱해야만 함. 즉,행렬 A₁, A₂, A₃, …, Aₙ 이 있을 때 A₃ × A₂ × A₁ 같이 역순으로 곱하면 안 됨.
- 행렬 곱셈은 결합법칙은 있지만, 교환법칙이 x.(AB)C = A(BC) 가능 AB ≠ BA ❌ 순서 바꾸면 안 됨.
점화식 이해 (슬라이드 4)
우리는 m(i, j) (Ai~Aj 곱하는 최소 연산량)를 구하고 싶음.
Ai × Ai+1 × … × Ak × Ak+1 × … × Aj
중간에 쪼개는 지점 k를 정한다면:
- m(i, k): Ai~Ak까지 곱하는 최소 비용
- m(k+1, j): Ak+1~Aj까지 곱하는 최소 비용
- row(Ai) * col(Ak) * col(Aj): 마지막으로 두 결과를 곱하는 비용
최적 분할 위치 K를 어떻게 구할까?
모든 가능한 k를 다 시도해보고, 그 중에서 곱셈 연산량이 가장 작은 k를 고릅니다. → 이게 바로 “브루트포스 + 동적 계획법” 방식
예시
목표: m[0][2]을 계산해보자 (즉, A₀ × A₁ × A₂) → 가능한 split은 k = 0 or k = 1
- k = 0:
m[0][0] + m[1][2] + p[0] * p[1] * p[3]
↘ ↘ ↘ ↘ ↘
A₀ A₁×A₂ A₀의 행 A₀의 열 A₂의 열
- A₀ 크기: 10×100 → p[0]×p[1]
- A₁×A₂ 결과 크기: 100×50 → (이건 계산 X, 크기만)
- 곱셈 비용: 10×100×50 = 50,000
- k = 1:
m[0][1] + m[2][2] + p[0] * p[2] * p[3]
- A₀×A₁ 결과 크기: 10×5 → p[0]×p[2]
- A₂ 크기: 5×50 → p[2]×p[3]
- 곱셈 비용: 10×5×50 = 2,500
Floyd-Warshall Algorithm
(All Pairs Shortest Path) 모든 경유 노드 k를 기준으로, i에서 j까지 k를 거치면 더 짧아질 수 있는지를 반복적으로 갱신
전제 조건
음수 가중치(edge)는 가능 → 음수 사이클(negative cycle)은 불가능! (경로 비용이 -∞가 되기 때문에 알고리즘이 무한히 반복될 수 있음)
구성요소
-
Initialize dist ← array of infinity → 모든 거리 값을 무한대로 초기화. 즉, 처음에는 모든 정점 간의 거리를 “아직 모른다”라고 가정
-
Initialize d[i, j] ← w(i, j) → 간선이 직접 연결된 경우는 가중치로 설정 예: i에서 j로 가는 간선이 있고 가중치가 3이라면 d[i][j] = 3 자기 자신은 d[i][i] = 0
-
삼중 루프 (핵심!)
for k in range(0, N): # 경유 노드 for i in range(0, N): # 출발 노드 for j in range(0, N): # 도착 노드 d[i][j] = min(d[i][j], d[i][k] + d[k][j])
- i에서 j까지 가는 기존 거리(d[i][j]) 와 k를 거쳐 가는 거리(d[i][k] + d[k][j]) 를 비교해서 더 짧은 거리로 갱신
시간복잡도
총 N × N × N = O(N³)
공간복잡도: O(N²)
d[i][j] 배열이 정점 간 거리 저장용이기 때문
Dijkstra’s Algorithm (단일 출발점)
단일 출발점 최단 경로 (Single-Source Shortest Path, SSSP) 계산, 가중치가 있는 그래프에서 출발 노드로부터 모든 노드까지의 최단 거리를 구함.
- Input: designated source node – start here
- Output: distance (& path) between source and each node
저장구조
- dist[u]: 시작 노드로부터 u까지의 최단 거리
- pred[u]: u까지 오는 데 사용된 바로 이전 노드 (경로 추적용) u’s predecessor in the shortest path.
시간 복잡도
- Q를 Min-Heap / 우선순위 큐 (Priority Queue) 로 구현 (ex: Python heapq)
- min dist[u] 선택 → O(log V)
각 정점에서 나가는 간선들을 업데이트할 때 → O(log V)
- 총 간선 수 E만큼 큐 갱신 → O(E log V)
최종 시간복잡도: O((V + E) log V)
Leave a comment