DP(Dynamic Programming)는 큰 문제를 작은 문제로 나누어 풀고, 이미 계산한 결과를 재사용해서 중복 계산을 줄이는 방법이다.
SRTBOT
이건 그냥 DP 문제를 풀 때 체크해야 하는 사고 순서라고 보면 된다.
즉,
예를 들어 피보나치로 보면:
fib(n) = fib(n-1) + fib(n-2)
여기서 fib(n)을 구할 때 할 일은 사실 로컬하게 보면 별거 없다.
그냥
fib(n-1)fib(n-2)이 두 개만 보면 된다.
이런 식으로 각 위치에서 선택지는 작고, 그 작은 선택들을 반복해서 전체 답을 만든다.
이제 하나씩 자세히 보자.
이게 제일 중요하다.
DP는 사실상 “문제를 어떻게 잘게 자를 거냐” 가 거의 전부다.
하위 문제를 잘 정의해야 한다.
예를 들어 피보나치에서는:
dp[i] = fib(i)계단 오르기 문제라면:
dp[i] = i번째 계단까지 가는 방법 수배낭 문제라면:
dp[i][w] = 앞에서 i개 물건만 고려해서, 무게 제한 w일 때 얻을 수 있는 최대 가치이런 식이다.
좋은 하위 문제는 보통 이런 특징이 있다:
DP를 못 푸는 대부분 이유는 점화식이 아니라 subproblem 정의를 이상하게 해서다.
예를 들어
“최대 합을 구한다” 라는 문제에서
dp[i] = 전체 배열에서 최대 합
이렇게 잡으면 안 된다.
왜냐면 이건 i와 상관없이 너무 뭉뚱그려져 있어서, 작은 문제로 쪼개진 게 아니기 때문이다.
대신
dp[i] = i에서 끝나는 최대 부분합
이런 식으로 잡아야 관계가 생긴다.
즉, subproblem은 작고, 구조가 있어야 한다
subproblem을 정의했으면, 이제 작은 문제들끼리 어떤 관계를 갖는지 써야 한다.
이게 바로 점화식이다.
예를 들어 피보나치:
dp[i] = dp[i-1] + dp[i-2]
계단 오르기:
dp[i] = dp[i-1] + dp[i-2]
배열 최대 부분합(Kadane 느낌):
dp[i] = max(dp[i-1] + a[i], a[i])
배낭 문제:
dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i])
보통 이런 질문을 던지면 된다:
DP 문제에서 자주 나오는 패턴은
“마지막 행동 기준으로 경우를 나눈다” 이다.
예를 들어 “i번째 계단에 도착했다”면 마지막 행동은:
그래서 dp[i] = dp[i-1] + dp[i-2]가 되는 거다.
DP는 결국
“답을 저장하는 재귀”
라고 생각해도 된다.
relation이 없으면 그냥 저장만 하는 거고, relation이 있으면 문제 구조가 드러난다.
DP에서 어떤 상태 dp[i]를 계산하려면,
그보다 먼저 필요한 상태들이 다 계산되어 있어야 한다.
예를 들어
dp[i] = dp[i-1] + dp[i-2]
라면 dp[i-1], dp[i-2]를 먼저 알아야 한다.
그래서 계산 순서는 보통
012n이 된다.
이걸 어려운 말로 topological order 라고 한다.
각 subproblem을 노드라고 생각하고,
“이 상태가 저 상태에 의존한다” 를 간선으로 보면,
의존 그래프가 생긴다.
그 그래프에서
의존하는 애보다, 의존받는 애를 먼저 계산하는 순서
가 topological order다.
쉽게 말하면:
이거다.
피보나치:
dp[0], dp[1] 먼저dp[2]dp[3]dp[n]2차원 DP에서도 마찬가지다.
예를 들어 dp[i][j]가 위쪽과 왼쪽에 의존하면:
채우면 된다.
즉, 점화식이 “누구를 참조하는지” 보면 계산 순서가 나온다.
점화식만 있으면 무한히 내려간다.
그래서 시작점이 필요하다.
피보나치면:
dp[0] = 0dp[1] = 1계단 오르기면 문제 정의에 따라:
dp[0] = 1dp[1] = 1이런 식이다.
점화식은 보통 이전 상태를 참조하는데,
맨 처음 상태들은 이전 상태가 없다.
그러면 직접 값을 박아줘야 한다.
이걸 안 하면:
base case는 그냥 “작은 입력 몇 개”가 아니라,
relation이 정상 작동하도록 만드는 최소 시작 상태다.
예를 들어 dp[i] = dp[i-1] + dp[i-2] 라면 최소 두 개는 필요하다.
DP 배열을 다 만들었다고 끝이 아니다.
그래서 최종 답이 뭐냐? 를 확인해야 한다.
예를 들어:
n번째를 구하면 정답은 dp[n]dp[n]dp[n]이 아닐 수도 있다예를 들어 LIS에서
dp[i] = i에서 끝나는 LIS 길이
라면 전체 답은
max(dp[0], dp[1], ..., dp[n-1])
이다.
즉, subproblem은 중간 상태를 나타낼 뿐이고,
원래 문제가 원하는 답이 정확히 어느 상태인지 확인해야 한다.
마지막으로 시간 복잡도를 봐야 한다.
DP 시간 분석은 보통 되게 단순하다:
총 상태 수 × 상태 하나 계산하는 비용
이 공식으로 거의 끝난다.
n그래서 총 시간: O(n)
n그래서 총 시간: O(n^2)
n * W그래서 총 시간: O(nW)
보통
예를 들어 피보나치는 사실 dp[i-1], dp[i-2]만 필요하니까 O(1) 공간으로도 가능하다.
메모이제이션은
한 번 계산한 값을 저장해두고, 다시 계산하지 않는 것이다.
보통 DP 구현 방식은 2개다.
재귀로 푼다.
필요한 문제를 호출해 내려가고,
이미 계산한 건 저장해두고 재사용한다.
피보나치 예시:
memo = {}
def fib(n):
if n in memo:
return memo[n]
if n == 0:
return 0
if n == 1:
return 1
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
작은 문제부터 직접 차례대로 채워나간다.
def fib(n):
if n == 0:
return 0
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
DP가 필요한 상황은 보통 두 가지 특징이 있다.
같은 하위 문제가 여러 번 반복된다.
예:
fib(5)를 재귀로 풀면 fib(3) 같은 게 여러 번 호출됨큰 문제의 최적해가 작은 문제의 최적해로부터 만들어진다.
예:
이 두 개가 있어야 DP 냄새가 난다.
한 번에 1칸 또는 2칸 올라갈 수 있을 때, n칸에 도달하는 방법 수를 구하라.
dp[i] = i칸에 도달하는 방법 수
마지막 이동 기준으로 보면:
그래서
dp[i] = dp[i-1] + dp[i-2]
dp[0], dp[1], dp[2], ... 순서로 계산
dp[0] = 1dp[1] = 1정답은 dp[n]
총 시간 O(n), 공간 O(n)
문제 보면 이렇게 생각하면 된다.
상태를 뭐로 둘까?
dp[i]?dp[i][j]?dp[i][j][k]?마지막 선택은 뭐지?
점화식은 어떻게 되지?
시작점은 뭐지?
어떤 순서로 채워야 하지?
최종 답은 어디 있지?
dp[n]?max(dp)?dp[0][n-1]?이 순서가 거의 SRTBOT이랑 같다.
Greedy는 매 순간 가장 좋아 보이는 선택을 하면서 답을 만들어 가는 방식이다.
작성 예정
이거 은근 시험에서 많이 틀린다 임마.