2025. 6. 12.
💡 DP란? (Dynamic Programming)
"반복되는 계산을 저장해서 중복 계산을 줄이는 방법"
: 큰 문제를 → 작은 문제로 쪼개서 → 풀어놓고 → 그 결과를 재사용
🔧 왜 필요한가?
완전탐색(DFS 등)은 모든 경우를 다 탐색
→ 시간 오래 걸림 (O(2ⁿ), O(n!) 등)
그런데 중복되는 계산이 많다.
👉 이미 한 번 계산했던 결과를 저장해서 재활용하면 빠름!
DP는 중복되는 계산을 한 번만 수행하고 저장해서, 불필요한 반복을 없애줌
🔑 DP의 핵심 아이디어
|
개념
|
설명
|
|
최적 부분 구조
|
큰 문제의 최적해가 작은 문제의 최적해로 구성됨
|
|
중복 부분 문제
|
같은 계산이 반복됨
|
|
메모이제이션 (Memoization)
|
계산한 결과를 저장해서 재사용
|
|
상향식 (Bottom-up)
|
작은 문제부터 차근차근 풀어 올라감
|
작은 문제부터 차례로 풀고 저장
같은 문제가 또 나오면 → 기존 저장값 사용
보통 배열 dp[] 에 저장함.
📦 예시로 쉽게 보기
유명한 배낭 문제 (Knapsack problem)로 예를 들어보면
문제: N개의 물건이 있고, 무게 제한이 W일 때, 최대 가치 구하기
|
물건
|
가치
|
무게
|
|
A
|
60
|
10
|
|
B
|
100
|
20
|
|
C
|
120
|
30
|
무게 제한이 50일 때 최대 가치는?
단순 조합으로 전부 다 탐색하면 경우의 수 너무 많음.
DP로 풀면:
dp[i][w]: i번째 물건까지 고려했을 때, 무게 w에서의 최대 가치
이걸 점화식으로 만들어서 풀면 O(N * W) 시간만에 해결 가능함.
|
적용하기 좋은 경우
|
예시
|
|
중복 계산이 많다
|
피보나치, 조합, 최단경로 등
|
|
부분 문제 결과가 전체 결과에 도움이 된다
|
배낭문제, 최대 부분합 문제 등
|
|
최적해를 구할 때
|
가장 큰 값, 최소 비용 등
|
이 문제에서 고민할 것:
각 물건을 "넣을까? 말까?" → 이게 바로 조합의 핵심.
모든 경우를 다 확인하면 총 2^3 = 8가지 경우가 있지만
DP는 이렇게 다 확인하지 않고 중복된 계산을 줄인다.
<코딩>
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
int N = 3; // 물건 3개
int W = 50; // 최대 무게 50
// 물건들의 무게, 가치
vector<int> weight = {10, 20, 30};
vector<int> value = {60, 100, 120};
// dp 테이블 만들기: (N+1)행, (W+1)열
vector<vector<int>> dp(N+1, vector<int>(W+1, 0));
// dp[0][w]는 물건 0개일 때 항상 0 → 초기값 0으로 채움.
for (int i = 1; i <= N; ++i) { // i: 1번 ~ N번 물건까지 고려
for (int w = 0; w <= W; ++w) { // w: 현재 배낭에 담은 무게
if (w >= weight[i-1]) { // 현재 무게 w가 이 물건을 담을 수 있을 때
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i-1]] + value[i-1]);
/* 이 물건을 담을지 말지 선택해서 최적값 저장
dp[i-1][w] → 안 담은 경우
dp[i-1][w - weight[i-1]] + value[i-1] → 담은 경우 둘 중 더 큰 값을 선택 */
} else {
dp[i][w] = dp[i-1][w];
} // 이 물건이 너무 무거워서 못 담으면 → 그냥 이전 값을 유지
}
cout << "최대 가치: " << dp[N][W] << endl;
return 0;
}
최종 dp 테이블
|
i / w
|
0
|
10
|
20
|
30
|
40
|
50
|
|
0
|
0
|
0
|
0
|
0
|
0
|
0
|
|
1
|
0
|
60
|
60
|
60
|
60
|
60
|
|
2
|
0
|
60
|
100
|
160
|
160
|
160
|
|
3
|
0
|
60
|
100
|
160
|
180
|
220
|
dp[3][50] = 220 이 정답!
DP 핵심 아이디어
dp[i][w] = 앞에서 i번째 물건까지 고려했을 때, 무게 w 이하로 담아서 만들 수 있는 최대 가치
"이전까지 계산한 결과를 저장해놓고 재활용하자!" → 이게 DP.
DP 점화식 (가장 핵심) : 물건을 하나씩 보면서, 매번 두 가지 선택을 한다.
1️⃣ 이 물건을 안 담을 경우:
dp[i][w] = dp[i-1][w]
(이전 결과 그대로 가져오기)
2️⃣ 이 물건을 담을 경우:
담으려면 무게 w가 weight[i-1]보다 커야 한다.
그러면: dp[i][w] = dp[i-1][w - weight[i-1]] + value[i-1]
👉 이 둘 중에서 더 큰 값을 선택.
추가 예시
예제: 피보나치 수열
일반 재귀 (비효율적, O(2ⁿ))
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
DP로 최적화 (O(n))
int dp[100];
int fib(int n) {
if (n <= 1) return n;
if (dp[n] != 0) return dp[n];
return dp[n] = fib(n-1) + fib(n-2);
}
→ 이미 구한 fib(n-1), fib(n-2)를 재사용
DP는 언제 쓰냐?
|
상황
|
DFS/백트래킹
|
DP
|
|
경우의 수가 적음
|
DFS
|
DFS
|
|
경우의 수가 많고 중복됨
|
DFS 느림
|
DP가 훨씬 빠름
|
|
최적해 구할 때
|
조건에 따라 다름
|
DP 추천
|
??? 근데 왜!!! 함수를 만들지 않고 main 함수에서 for문을 도는 방식을 사용하는가?!?!?!?
1️⃣ 지금까지 본 구조
for (int i = 1; i <= N; ++i) {
for (int w = 0; w <= W; ++w) {
... dp 채우기 ...
}
}
보통 DP 문제는 이렇게 **"테이블을 채워나가는 반복문"**으로 바로 푸는 경우가 많음
이 방식이 좋은 이유:
DP 테이블을 bottom-up 방식으로 한 번에 채워서 효율적.
불필요한 함수 호출이 없다 → 속도가 빠르다.
메모리와 시간 낭비가 적다.
2️⃣ 함수로 분리하는 방법
void knapsackDP(vector<int>& weight, vector<int>& value, int N, int W, vector<vector<int>>& dp) {
for (int i = 1; i <= N; ++i) {
for (int w = 0; w <= W; ++w) {
if (w >= weight[i-1])
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i-1]] + value[i-1]);
else
dp[i][w] = dp[i-1][w];
}
}
}
int main() {
...
knapsackDP(weight, value, N, W, dp);
cout << dp[N][W];
}
3️⃣
3️⃣ 그럼 왜 보통 그냥 for문으로 작성하나?
✔ DP는 일반적으로 함수 호출이 반복적으로 필요하지 않음
→ 어차피 테이블 전체를 채우니까 호출보단 바로 채우는게 효율적
✔ 함수 호출 방식은
가독성은 조금 더 좋을 수 있음
속도는 약간 손해볼 수 있음 (매번 인자 전달 등)
✔ 특히 C++은 함수 호출 비용 (call overhead) 이 꽤 있기 때문에
for문으로 바로 채우는게 실전 대회, 알고리즘 문제 풀 때는 더 빠름.
4️⃣ 근데 함수로 쪼개야 할 때도 있음:
여러 DP 문제를 동시에 풀 때
knapsack 부분을 라이브러리화할 때
코드 재사용성을 높일 때
|
방식
|
특징
|
주로 사용하는 곳
|
|
for문 내부에서 DP
|
가장 빠르고 실전적
|
알고리즘 문제풀이, 대회
|
|
함수 분리 DP
|
코드 재사용성, 가독성
|
실무, 라이브러리 작성
|
"DP는 결국 테이블을 채우는 일이다" → 이걸 어떻게 조직하느냐가 차이일 뿐
✅ DFS + 백트래킹 (조합식 코드)와의 차이
DFS(깊이 우선 탐색)는 사실상 이런 식의 분기 구조를 가짐
[시작]
/ \
선택 선택X
/ \ ...
선택 선택X
이처럼 *분기(branch)*가 계속 생기기 때문에, 재귀 함수로 표현하는 게 아주 직관적이고 깔끔함
void Combination(...) {
if (...) {
...
} else {
// 선택하는 경우
Combination(...);
// 선택하지 않는 경우
Combination(...);
}
}
📌 이 방식의 특징:
상태공간 탐색 (State space search)
→ 가능한 모든 경우의 수를 재귀적으로 탐색.
매 선택마다 "뽑을까 말까" → 트리 구조로 분기.
재귀함수 호출이 자연스럽게 상태를 스택으로 유지해줘서
→ sum_s, sum_c 를 매 호출마다 별도로 관리 가능.
재귀 호출이 많긴 하지만 메모리는 적게 쓴다 (스택 활용).
✅ 함수 호출이 유리한 이유
|
항목
|
설명
|
|
각 단계마다 새로운 선택을 시도
|
재귀 호출로 분기마다 새로운 상태를 넘겨주기 쉬움
|
|
현재 상태(sum, idx 등) 보존
|
매 재귀 호출에서 지역 변수처럼 값이 유지됨
|
|
백트래킹이 자연스럽다
|
함수 호출이 끝나면 자동으로 이전 상태로 돌아감
|
|
코드가 짧고 직관적
|
직접 stack을 안 써도 스택처럼 동작함 (call stack)
|
각 단계에서 상태를 유지해주는 데 함수 호출 자체가 유용.
로직을 나누기도 간결함 (선택 vs 비선택)
백트래킹은 "불가능한 가지 빨리 자르기" 에 최적 → pruning 쉽게 가능
✅ DP (Dynamic Programming)
📌 DP의 특징:
중복되는 부분문제(subproblem)를 한번만 푼다 → 효율적.
테이블(dp[][] 등)을 만들어서 반복문으로 상태 저장.
함수 호출은 필요 없고, 주로 for문으로 채움.
상태 유지가 테이블에 있으므로 별도 스택 관리가 필요 없다.
상태가 많을수록(조합 수가 많을수록) 효율성이 급격히 올라간다.
✅ 비교표
|
방식
|
DFS + 백트래킹
|
DP
|
|
상태 관리
|
재귀 호출 스택
|
DP 테이블 (반복문 중심 or 재귀+메모이제이션)
|
|
코드 스타일
|
간결, 직관적
|
반복문 위주
|
|
시간복잡도
|
O(2^N) (완전탐색)
|
O(NW), O(N^2), 최적
|
|
중복계산
|
분기마다 계산
=> 많음 (따라서 가지치기 필요)
|
전체 경우를 메모리에 저장
=> 없음 (저장해둠)
|
|
문제 유형
|
작은 입력, 조합, 가지치기
조합/순열/게임판 탐색 등
|
큰 입력, 최적화 문제
최댓값/최솟값/경우 수 등
|
|
활용 목적
|
상태 추적/백트래킹이 목적
|
최적값 계산이 목적
|
✅ 쉽게 말하면:
| 🎯 DFS+백트래킹 → "경우의 수를 다 보는 문제" |
| 🎯 DP → "최적의 값을 구하는 문제" (부분문제 중복 존재) |
💡 결론
조합으로 합 최대 구하기 문제는:
DFS+백트래킹 → 재귀함수 매우 적합 (조합이 본질이니까)
배낭문제 (Knapsack Problem) → DP가 훨씬 유리 (부분문제 중복)
??? 이차원 배열을 만들면 vector의 크기가 너무 커지지 않는가?!?!?!?
vector<vector<int>> dp(N+1, vector<int>(W+1, 0));
2차원 DP 테이블을 만든다 → 공간 복잡도: O(N * W)
지금은 N=3, W=50 이니까: 3 * 50 = 150 칸 → 부담 없는데
만약 N=1000, W=100000 이 되면 → 메모리 초과 가능
그래서 공간 최적화가 자주 사용된다
사실 이 문제는
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i-1]] + value[i-1]);
이렇게 이전 행(i-1행) 만 사용하니까
👉 매번 이전 결과만 유지하면 된다.
최적화된 1차원 DP (공간복잡도: O(W))
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
int N = 3;
int W = 50;
vector<int> weight = {10, 20, 30};
vector<int> value = {60, 100, 120};
vector<int> dp(W+1, 0);
for (int i = 0; i < N; ++i) {
//물건 하나씩 확인! 지금 보고 있는 물건의 무게 = weight[i], 이 물건의 가치 = value[i]
for (int w = W; w >= weight[i]; --w) {
// 지금 무게 w에서 물건을 넣을 수 있는지 확인 (w가 weight[i]보다 크거나 같아야 함)
// 왜 거꾸로 도냐? 중복 사용을 막기 위해 (0-1 knapsack은 한 물건을 한 번만 선택 가능)
//dp[w]를 덮어쓸 때 이미 현재 i번 물건을 쓰기 전의 dp[w-weight[i]]값을 사용하려고 거꾸로 감.
dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
//dp[w]: 현재 무게 w까지 넣었을 때 최대 가치
// 선택지 2가지 중 더 큰 값을 dp[w]에 저장
// 안 넣는다 → dp[w] 유지 / 넣는다 → dp[w - weight[i]] + value[i] (이전 무게에서 현재 물건 더하기)
}
}
cout << "최대 가치: " << dp[W] << endl;
return 0;
}
그니까 다시 설명하면
weight = {10, 20, 30}
value = {60, 100, 120}
W = 50
첫 번째 물건 (i=0, 무게 10, 가치 60)
w=50 → 10까지 거꾸로 가면서 dp 갱신:
// 첫 번째 물건 (i=0, 무게 10, 가치 60)
// w=50 → 10까지 거꾸로 가면서 dp 갱신:
w=50 → dp[50] = max(dp[50], dp[40]+60)
w=49 → dp[49] = max(dp[49], dp[39]+60)
...
w=10 → dp[10] = max(dp[10], dp[0]+60) → dp[10]=60
// 결과적으로 무게 10짜리 물건은 dp[10]에 들어감
// 두 번째 물건 (i=1, 무게 20, 가치 100)
w=50 → dp[50] = max(dp[50], dp[30]+100)
...
w=20 → dp[20] = max(dp[20], dp[0]+100)
// 이런 식으로 계속 누적!
|
2차원 DP
|
1차원 DP
|
|
dp[i][w]
|
dp[w]
|
|
공간복잡도 O(N*W)
|
공간복잡도 O(W)
|
|
구현은 더 직관적
|
조금 더 신경써야함 (역순 for문)
|
🔧 왜 역순으로 w를 순회?
1차원 dp에서 정순으로 하면 같은 i에서 업데이트한 dp[w]를 다음 w에서 또 써버림.
역순으로 하면 이전 i에서의 dp[w-weight[i]]를 안전하게 참조 가능.
앞에서 이미 i번째 물건이 반영된 dp[w]값을 또 사용하면 중복 사용 위험이 있어서.
0-1 knapsack은 한 물건은 최대 1번만 넣어야 하니까!
이전 상태를 그대로 보존하기 위해 w를 역순으로 돈다.
"위에서부터 쌓아가는 느낌이 아니라, 뒤에서부터 덮어쓰면서 쌓아간다"
??? 그런데 이 방법도 W만큼 용량을 사용하는거니까
W가 커지면 용량이 커지고 안쓰는 공간이 많아져서
공간 사용이 비효율적인게 아닐까?!?!?!?
W가 커지면? → "메모리 최적화 기법"을 고민해야 함
W가 커지면 메모리 공간은 여전히 O(W)만큼 필요
예를 들어 W=10^6 이라면 → dp[W+1] → 약 1MB~4MB 까진 괜찮지만,
W가 10^7 넘어가면 → 수십 MB, 수백 MB → 때로는 메모리 초과
실제로 채워지는 값들은 전체 W 중 일부만 사용될 수도 있다.
이 문제를 "희소한 knapsack" 이라고 해
W는 크고
실제로 접근하는 weight들은 제한적일 때
→ 메모리가 낭비되는 경우
그래서 나오는 고급 테크닉들
① map 기반 knapsack (희소 DP)
#include <map>
map<int, int> dp;
key: weight
value: 가치
→ 메모리를 weight가 실제로 필요한 경우에만 사용
하지만 속도는 일반 dp보다 느릴 수 있음 (logN 탐색)
② meet in the middle
(N이 크고 W도 큰 경우)
아이템을 반으로 나눠서 → 각각 부분합 전부 구함 → 합쳐서 최적해 찾음
시간복잡도 O(2^(N/2)) → N이 40정도까지 가능
③ bitset DP (정수 knapsack에서)
#include <bitset>
bitset<MAX_W> dp;
메모리 사용 최소화 (비트 1개 = 1bit)
일부 문제에서는 속도가 매우 빨라짐
✅ 정리하면:
|
방법
|
메모리
|
속도
|
|
2차원 DP
|
O(N*W)
|
빠름
|
|
1차원 DP
|
O(W)
|
빠름
|
|
map DP
|
희소 O(사용 weight 수)
|
느림
|
|
meet in the middle
|
O(2^(N/2))
|
N작을 때
|
|
bitset DP
|
비트당 1bit
|
특수 상황
|
'이론공부 > 혼자 공부' 카테고리의 다른 글
| 그리디(Greedy) 알고리즘 | 탐욕법 (0) | 2025.10.28 |
|---|---|
| 다익스트라 알고리즘 (0) | 2025.10.28 |
| 모듈러 연산(Modular Arithmetic) (0) | 2025.10.08 |
| BFS (Breadth-First Search, 너비 우선 탐색) (1) | 2025.06.26 |
| DFS (Depth-First Search, 깊이 우선 탐색) (0) | 2025.06.26 |