# 11-1. 동적 계획법 알아보기
동적 계획법(dynamic programming)이란?
- 복잡한 문제를 여러 개의 간단한 문제로 분리하여 부분의 문제를 해결함으로써 최종적으로 복잡한 문제의 답을 구하는 방법
DP의 핵심 이론
- 원리
- 큰 문제들을 작은 문제로 나눌 수 있어야 한다.
- 작은 문제들이 반복돼 나타나고 사용되며 이 작은 문제들의 결괏값은 항상 같아야 한다.
- 메모제이션(memozation) 기법 : 모든 작은 문제들은 한 번만 계산해 DP 테이블에 저장하며 추후 재사용할 때는 이 DP 테이블을 이용한다.
- DP는 top-down 혹은 bottom-up 방식으로 구현할 수 있다.
- 예시(피보나치 수열) : D[N] = D[N-1] + D[N-2]
- https://www.acmicpc.net/problem/2747
- top-down 구현 방식은 주로 재귀 함수 형태로 구현한다. 코드의 가독성이 좋고 이해하기 편하다.
import sys
input = sys.stdin.readline
N = int(input())
D = [-1]*(N+1)
D[0] = 0
D[1] = 1
def fibo(n):
if D[n]!=-1: # 기존에 계산한 적이 있는 부분의 문제는 계산하지 않고 리턴
return D[n]
# 메모리제이션
D[n] = fibo(n-2)+fibo(n-1)
return D[n]
fibo(N)
print(D[N])
- bottom-up 구현 방식은 주로 반복문의 형태로 구현한다. 좀 더 안전한 방식(재귀의 깊이를 고려할 필요x)
import sys
input = sys.stdin.readline
N = int(input())
D = [-1]*(N+1)
D[0] = 0
D[1] = 1
for i in range(2, N+1):
D[i] = D[i-2] + D[i-1] # 반복문을 사용해서 아래부터 값을 채워나가는 방식
print(D[N])
https://www.acmicpc.net/problem/1463
import sys
input = sys.stdin.readline
N = int(input())
D = [0]*(N+1)
D[1] = 0
for i in range(2, N+1):
D[i] = D[i-1] + 1
if i%2==0:
D[i] = min(D[i], D[i//2]+1)
if i%3==0:
D[i] = min(D[i],D[i//3]+1)
print(D[N])
- D[i] = i에서 1로 만드는 데 걸리는 최소 연산 횟수
- 점화식을 구한다. 2 혹은 3으로 나누는 연산이 가능한 경우 D[i-1]+1과 D[i/3]+1 중 최솟값으로 채운다.
https://www.acmicpc.net/problem/14501
import sys
input = sys.stdin.readline
N = int(input())
D = [0]*(N+2) # 오늘부터 퇴사일까지 벌 수 있는 최대 수입
T = [0]*(N+1) # 상담에 필요한 일수
P = [0]*(N+1) # 상담 완료 시 수입
for i in range(1, N+1):
T[i], P[i] = map(int, input().split())
for i in range(N, 0, -1):
if i+T[i] > N+1:
D[i] = D[i+1]
else:
D[i] = max(D[i+1], P[i]+D[i+T[i]])
print(D[1])
- D[i] = i번째 날부터 퇴사날까지 벌 수 있는 최대 수입
- 오늘 시작되는 상담을 했을 때 퇴사일까지 끝나지 않는 경우 D[i] = D[i+1]
- 오늘 시작되는 상담을 했을 때 퇴사일 안에 끝나는 경우 D[i] = MAX(D[i+1], D[i+T[i]] + P[i]
- 상담의 뒤에서부터 접근한다!
https://www.acmicpc.net/problem/2193
import sys
input = sys.stdin.readline
N = int(input())
D = [[0 for j in range(2)] for i in range(N+1)]
D[1][1] = 1 # 1자리 이친수는 1 한가지만 존재
D[1][0] = 0
for i in range(2, N+1):
D[i][0] = D[i-1][1] + D[i-1][0]
D[i][1] = D[i-1][0]
print(D[N][0] + D[N][1])
- D[i][0] = i 길이에서 끝이 0으로 끝나는 이친수의 개수, D[i][1] = i길이에서 끝이 1로 끝나는 이친수의 개수
- 이전 단계에서 0으로 끝나는 경우에만 1을 붙인다.
https://www.acmicpc.net/problem/11726
import sys
input = sys.stdin.readline
mod = 10007
N = int(input())
D=[0]*1001
D[1] = 1 # 길이가 2x1일 때 타일의 경우의 수
D[2] = 2 # 길이가 2x2일 때 타일의 경우의 수
for i in range(3, N+1):
D[i] = (D[i-1]+D[i-2])%mod # 점화식
print(D[N])
- 경우의 수는 현재 N번째 타일을 채울 때, N-1 타일까지 채워져 있거나 N-2 타일까지 채워야 하는 경우로 나눌 수 있다.
- N-1인 경우 세로로 타일 1개를 채우고, N-2인 경우 가로로 타일 2개를 채운다.(세로로 2개는 N-1의 경우와 같음)
- D[N] = D[N-1] + D[N-2] = 길이 N으로 만들 수 있는 타일의 경우의 수
- DP 테이블을 채울 때마다 %연산을 수행한다.
https://www.acmicpc.net/problem/10844
import sys
input = sys.stdin.readline
mod = 1000000000
N = int(input())
D=[[0 for j in range(11)] for i in range(N+1)]
# D[i][j] = 길이가 i일때 높이 j로 끝나는 모든 경우의 수
for i in range(1, 10):
D[1][i] = 1 # 길이가 1일 때 만드는 높이 H로 끝나는 계단 수의 모든 경우의 수는 1
for i in range(2, N+1):
D[i][0] = D[i-1][1] # N에서 높이가 0이면 N-1에서는 높이가 1이여야 계단 수가 가능
D[i][9] = D[i-1][8] # N에서 높이가 9이면 N-1에서는 높이가 8이여야 계단 수가 가능
for j in range(1,9):
# 높이가 1~8이면 N-1에서 자신보다 한 단계 위 혹은 한 단계 아래 높이에서 올 수 있음
D[i][j] = (D[i-1][j-1] + D[i-1][j+1])%mod
sum = 0
for i in range(10):
sum = (sum+D[N][i])%mod # 정답값을 더할 때도 %연산 수행
print(sum)
- D[N][H] = 길이가 N인 계단에서 H 높이로 종료되는 계단 수를 만들 수 있는 경우의 수
- 즉 D[N][5]에서, N-1에 올 수 있는 수는 4와 6이다.
- D[N][0]~D[N][9]의 모든 값을 더하면 모든 경우의 수를 구할 수 있다.
https://www.acmicpc.net/problem/13398
import sys
input = sys.stdin.readline
mod = 1000000000
N = int(input())
A = list(map(int, input().split()))
# 오른쪽으로 index를 포함한 최대 연속 합 구하기
L = [0]*N
L[0] = A[0]
result = L[0]
for i in range(1, N):
L[i] = max(A[i],L[i-1]+A[i])
result = max(result, L[i]) # 제거하지 않았을 때는 기본 최댓값으로 저장
# 왼쪽으로 index를 포함한 최대 연속 합 구하기
R = [0]*N
R[N-1] = A[N-1]
for i in range(N-2, -1,-1):
R[i] = max(A[i], R[i+1]+A[i])
# L[i-1] + R[i-1] 2개의 구간 합 리스트를 더하면 i번 째 값을 제거한 효과를 얻음
for i in range(1, N-1):
temp = L[i-1]+R[i+1]
result = max(result, temp)
print(result)
- D[N] = 0부터 N까지의 길이에서 N을 포함하며 연속으로 수를 선택하여 구할 수 있는 최대 합
- 1개의 수를 삭제할 수 있으므로, 왼쪽과 오른쪽 점화식을 구한다. L[N-1]+R[N+1] 은 N을 1개 제거한 최댓값을 구하는 효과가 있다.
https://www.acmicpc.net/problem/9252
- LCS 문제!
- 문자열 비교 문제는 이 문제와 비슷한게 많으므로 꼼꼼하게 숙지할 것.
import sys
input = sys.stdin.readline
sys.setrecursionlimit(10000)
A = list(input())
A.pop() # \n문자열 제거
B = list(input())
B.pop() # \n 문자열 제거
DP = [[0 for j in range(len(B)+1)] for i in range(len(A)+1)]
Path = []
for i in range(1, len(A)+1):
for j in range(1, len(B)+1):
if A[i-1]==B[j-1]:
# 같은 문자열일 때 왼쪽 대각선 값+1
DP[i][j] = DP[i-1][j-1]+1
else:
# 다르면 왼쪽과 위의 값 중 큰 수
DP[i][j] = max(DP[i-1][j], DP[i][j-1])
print(DP[len(A)][len(B)])
# LCS 구현 함수
def getText(r,c):
if r==0 or c==0:
return
if A[r-1]==B[c-1]: # 같으면 LCS에 기록하고 왼쪽 위로 이동
Path.append(A[r-1])
getText(r-1, c-1)
else: # 다르면 왼쪽과 위의 값 중 큰 수로 이동
if DP[r-1][c]>DP[r][c-1]:
getText(r-1,c)
else:
getText(r,c-1)
getText(len(A),len(B))
for i in range(len(Path)-1,-1,-1):
print(Path.pop(i), end='')
print()
- 각 문자열을 축으로 하는 2차원 리스트를 생성한다.
- 테이블에 저장되는 값은 각 위치 인덱스를 마지막 문자로 하는 두 문자열의 LCS의 길이이다.
- LCS 점화식을 이용해 값을 채운다. 특정 자리가 가리키는행과 열의 문자열 값을 비교한다.
- 값이 같으면 테이블의 대각선 왼쪽 위의 값에 1을 더한 값을 저장한다. DP[i][j]=DP[i-1][j-1]+1
- 값이 다르면 테이블 왼쪽과 위쪽 값 중 큰 값을 선택해 저장한다. DP[i][j] = Max(DP[i-1][j], DP[i][j-1])
- LCS를 출력한다. 마지막부터 탐색을 수행하고 해당 자리에 있는 문자열 값을 비교해 값이 같으면 LCS(Path)에 기록하고 왼쪽 대각선으로 이동한다. 비교한 값이 다르면 테이블의 위쪽과 왼쪽 중 큰 값으로 이동한다.
https://www.acmicpc.net/problem/1915
import sys
input = sys.stdin.readline
D = [[0 for j in range(1001)] for i in range(1001)]
N, M = map(int, input().split())
max = 0
for i in range(0, N):
numbers = list(input())
for j in range(0, M):
D[i][j] = int(numbers[j])
if D[i][j] == 1 and j>0 and i >0:
D[i][j] = min(D[i-1][j-1], D[i-1][j], D[i][j-1])+D[i][j] # 점화식
if max<D[i][j]:
max = D[i][j]
print(max*max)
- 가장 큰 정사각형의 한 변의 길이를 구한다.
- D[i][j] = i,j의 위치를 오른쪽 아래 꼭짓점으로 정하고, 해당 자리에서 그릴 수 있는 가장 큰 정사각형의 변의 길이
- 즉, 현재 위치에서 위, 왼쪽, 대각선 왼쪽 위에 있는 값 중 가장 작은 값에 1을 더한 값으로 변경한다. 원래 값이 0인 경우에는 그대로 둔다.
https://www.acmicpc.net/problem/1328
import sys
input = sys.stdin.readline
mod = 1000000007
N,L,R= map(int, input().split())
D = [[[0 for k in range(101)] for j in range(101)] for i in range(101)]
D[1][1][1] = 1 # 빌딩이 1개이면 놓을 수 있는 경우의 수는 1개
for i in range(2, N+1):
for j in range(1, L+1):
for k in range(1, R+1):
D[i][j][k] = (D[i-1][j][k]*(i-2) + (D[i-1][j][k-1] + D[i-1][j-1][k]))%mod
print(D[N][L][R])
- D[N][L][R] = N개의 빌딩이 있고 왼쪽에서 L개, 오른쪽에서 R개가 보일 떄 가능한 경우의 수
- 가장 작은 빌딩을 N번째로 배치할 때 왼쪽/오른쪽/가운데로 나뉜다.
- 왼쪽의 경우 왼쪽에서 보는 빌딩 증가, 오른쪽의 경우 오른쪽에서 보는 빌딩 증가, 가운데는 증가 X
- 즉 D[N-1]개의 빌딩에서 왼쪽에 빌딩을 추가할 때 이전 경우의 수는 D[N-1][L-1][R]이다.
- 오른쪽에 추가하는 경우 D[N-1][L][R-1]이다.
- 가운데의 경우 증가 수는 없지만, N-2개에 위치할 수 있으므로 D[N-1][L][R]*(N-2)
- 위 3가지 경우의 수를 모두 더하면 점화식이 나온다.
https://www.acmicpc.net/problem/2342
import sys
input = sys.stdin.readline
# dp[N][L][R] = N개 수열을 수행했고, 왼쪽이 L 오른쪽이 R에 있을 때 최소 누적
# 충분히 큰 수로 초기화
dp = [[[sys.maxsize for k in range(5)] for j in range(5)] for i in range(100001)]
# 한 발을 이동할 때 드는 힘을 미리 저장하기(mp[1][2] -> 1에서 2로 이동할 때 드는 힘)
mp = [[0, 2, 2, 2, 2],
[2, 1, 3, 4, 3],
[2, 3, 1, 3, 4],
[2, 4, 3, 1, 3],
[2, 3, 4, 3, 1]]
s = 1
dp[0][0][0] = 0
inputList = list(map(int, input().split()))
index = 0
while inputList[index]!=0:
n = inputList[index]
for i in range(5):
if n==i: # 두 발이 같은 자리에 있을 수 없음
continue
for j in range(5):
# 오른발을 옮겨 현재 모습이 됐을 때 최소 힘 저장
dp[s][i][n] = min(dp[s-1][i][j] + mp[j][n], dp[s][i][n])
for j in range(5):
if n==j: # 두 발이 같은 자리에 있을 수 없음
continue
for i in range(5):
# 왼발을 옮겨 현재 모습이 됐을 때 최소 힘 저장
dp[s][n][j] = min(dp[s-1][i][j] + mp[i][n], dp[s][n][j])
s+=1
index+=1
s-=1 # 마지막 이동 횟수로 index 조정
result = sys.maxsize
for i in range(5):
for j in range(5):
result = min(result, dp[s][i][j]) # 모두 수행한 후 최솟값 찾기
print(result)
- 수열의 최대 길이가 10만 이므로 모든 경우의 수를 점화식으로 표현해 구현한다.
- D[N][L][R] = N개의 수열을 수행한 후 왼발의 위치가 L, 오른발의 위치가 R일 때 최소 누적 힘
- 예를 들어, 직전에 오른 다리가 2의 자리에 있다가 현재 R 자리로 이동했다면 D[N][L][R]의 최솟값 후보 중 하나로 D[N-1][L][2] + (2 -> R로 이동한 힘)이 될 수 있다.
- 즉 오른발의 경우 D[N][L][R] = MIN(D[N-1][L][i] + mp[i][R])
- 왼발은 D[N][L][R] = MIN(D[N-1][i][R] + mp[i][L])
https://www.acmicpc.net/problem/11049
import sys
input = sys.stdin.readline
N = int(input())
M = []
D = [[-1 for j in range(N+1)] for i in range(N+1)]
M.append((0,0))
for i in range(N):
x,y = map(int, input().split())
M.append((x,y))
def execute(s,e): # 탑-다운 방식 점화식 함수
result = sys.maxsize
if D[s][e] != -1: # 이미 계산한 부분은 다시 계산하지 않음(메모리제이션)
return D[s][e]
if s==e: # 행렬 1개의 곱셈 연산
result = 0
if s+1==e: # 행렬 2개의 곱셈 연산
return M[s][0]*M[s][1]*M[e][1]
for i in range(s,e): # 행렬 3개 이상일 경우 곱셈 연산 -> 재귀 형태로 계산
result = min(result, M[s][0]*M[i][1]*M[e][1] + execute(s,i)+execute(i+1, e))
D[s][e] = result
return D[s][e]
print(execute(1, N))
- D[i][j] = i~j 구간의 행렬을 합치는 데 드는 최소 연산 횟수
- 즉 N번째 행렬을 합칠 때, D[1][N-1], D[N][N]값을 알고 있으므로 D[1][N-1]+D[N][N] +a (1개의 행렬로 합치는 데 드는 횟수)가 된다.
- a는 두 행렬을 합치는데 드는 값으로 1번째 행렬의 row 개수, N번째 행렬의 row 개수, column 개수를 곱한다.
- 2x3, 3x6 행렬을 합치는 경우 a = 2x3x6 = 36
- 구하려는 영역의 행렬 개수가 3개 이상일 때는 이 영역을 재귀 형태로 쪼개면서 계산한다.
- Python 시간초과나서 PyPy로 제출함.
https://www.acmicpc.net/problem/2098
- 중요한 TSP 문제! DFS, DP, 비트연산까지 나오는데 이게 플레가 아니라니..
- 음 책 그대로 썼는데 실수를 했는지 틀리게 나온다...비슷한 다른 풀이 참고함.
import sys
N = int(input())
world = []
for _ in range(N):
world.append(list(map(int, sys.stdin.readline().split())))
dp = {}
def DFS(now, visited):
# 모든 도시를 방문한 경우
if visited == (1 << N) - 1:
# 다시 출발 도시로 갈 수 있는 경우 출발 도시까지의 비용 반환
if world[now][0]:
return world[now][0]
else:
# 갈 수 없는 경우 무한대 반환 (이 경로가 최소비용으로 채택되지 않게)
return int(1e9)
# 이전에 계산된 경우 결과 반환
if (now, visited) in dp:
return dp[(now, visited)] # now까지 방문한 최소 비용
min_cost = int(1e9)
for next in range(1, N):
# 비용이 0이어서 갈 수 없거나, 이미 방문한 루트면 무시
if world[now][next] == 0 or visited & (1 << next):
continue
cost = DFS(next, visited | (1 << next)) + world[now][next]
min_cost = min(cost, min_cost)
dp[(now, visited)] = min_cost # 현재도시까지 방문한 경우 중에서 최소 비용이 드는 루트의 비용 저장
return min_cost # 현재도시까지 방문하는 비용 리턴
print(DFS(0, 1)) # now: 0번째 도시부터 방문, visited: 0번째 도시 방문 처리
- D[c][v] = 현재 도시가 c, 현재까지 방문한 모든 도시 리스트가 v일 떄 앞으로 남은 모든 도시를 경유하는 데 필요한 최소 비용
- v에 현재까지 방문한 리스트를 저장하기 위해 이진수를 이용한다.
- 예를 들어, 4번, 1번을 방문한 경우 이진수로 1001로 표현하고, D[i][9]로 리스트를 표현한다.
- D[c][v] = MIN(D[c][v], D[i][v | (1<<i)] +W[c][i]) -> W[c][i]는 c에서 i로 가기 위한 비용
- << : 비트 연산 식. 15를 이진수로 표현하면 1111이며 (1<<4)로 나타낼 수 있다. 각 자리가 모두 1이므로 모든 도시를 방문한 상태이다.
- i=3일 때, 1<<i==1<<3==8이고 이진수로 1000으로 표현한다. v&1000 연산의 결괏값이 0이면 4번째 도시를 방문하지 않은 것으로 판단한다.
- W 리스트를 저장 후 점화식으로 정답을 구한다.
https://www.acmicpc.net/problem/14003
import sys
input = sys.stdin.readline
N = int(input())
A = list(map(int, input().split()))
A.insert(0,0)
index = 0
maxLength = 1
B = [0]*1000001
D = [0]*1000001
ans = [0]*1000001
B[maxLength] = A[1]
D[1]=1
# 바이너리 서치 구현
def binarysearch(l, r, now):
while l<r:
mid = (l+r)//2
if B[mid]<now:
l = mid+1
else:
r = mid
return l
for i in range(2, N+1):
if B[maxLength]<A[i]: # 가장 마지막 수열보다 현재 수열이 큰 경우
maxLength+=1
B[maxLength] = A[i]
D[i] = maxLength
else: # binarysearch를 이용해 현재 수열이 들어갈 index 찾기
index = binarysearch(1, maxLength, A[i])
B[index] = A[i]
D[i] = index
print(maxLength)
index = maxLength
x = B[maxLength]+1
for i in range(N, 0, -1): # 뒤에서부터 탐색하면서 정답 수열 저장
if D[i]==index:
ans[index]=A[i]
index-=1
for i in range(1, maxLength+1):
print(ans[i], end=' ')
- D[i] = 0~i까지 i를 포함하는 가장 길게 증가하는 수열의 길이. i를 포함하는 것이 중요함
- A[i]를 i번째 수열의 값이라고 정의하면 D[k]는 A[i]>A[k]를 만족하는 최대 수열의 길이이다. 즉, A[i]보다 작은 값을 지니고 있는 수열의 최장 증가 수열의 길이들 중 최댓값을 차자 +1한 후 D[i]에 저장한다.
- D[i] = max({D[k]}) + 1(K=1부터 i-1까지)
- 점화식을 이용해 D 리스트의 값을 저장한다. 이때 자신보다 작은 값을 지니고있는 최장 증가 수열 길이를 찾기 위해 B 리스트를 만든다.탐색은 binarySearch로 구현.
- D 리스트를 이용해 정답을 출력한다. 뒤에서부터 탐색해 최댓값과 동일한 값을 가지는 최초 index의 A[]값을 출력한 후 값을 1만큼 감소시키고 index가 1이 될때까지 반복한다.
# 회고
점화식은 정말 다양하게 풀 수 있고, 이 문제를 푸는 아이디어를 생각하는게 중요하다.
나는 공식을 적용하는게 좋다...그치만 창의적으로 문제에 대해 생각하고...해답을 만들어내는 것도 연습해야겠지ㅜ
'코딩테스트' 카테고리의 다른 글
[Python] 기하 (0) | 2024.06.07 |
---|---|
[Python] 동적 프로그래밍 문제 풀이 정리(1) (1) | 2024.06.06 |
[Python] 조합 문제 풀이 정리(2) (0) | 2024.05.30 |
[Python] 조합 문제 풀이 정리(1) (0) | 2024.05.29 |
[Python] 조합 (0) | 2024.05.25 |