알고리즘 - 다이나믹 프로그래밍
알고리즘 문제에서 흔히들 동적계획법
으로 알려져 있는 다이나믹 프로그래밍에 대한 개념설명과 기초 문제 풀이이다. 매우 기초를 벗어난 모든 알고리즘 문제는 이 접근법을 필수적으로 사용하는 경우가 많기 때문에 반드시 숙지해야 하는 알고리즘이다.
다이나믹 프로그래밍
큰 문제를 작은 문제로 나눠서 푸는 알고리즘이다
다이나믹 프로그래밍의 속성
- 작은 문제(부분 문제)가 모두 겹친다
- 최적 부분 구조이다. 작은(부분) 문제의 답이 결과 값을 구할때의 과정에 사용된 값과 항상 같다.
예를들어 피보나치 문제의 경우에는 작은 문제 두개의 합으로 큰 문제를 구한다. 또한 계속해서 나타나는 작은 문제들의 값이 중복적으로 같다.
- 다이나믹 프로그래밍에서 각 문제는 한번만 푼다
- 최적 부분구조를 만족하므로, 같은 문제는 항상 답이 같다
- 정답을 구했으면, 어딘가에 저장(메모)한다
- 메모는 배열에 저장하는것으로 할 수 있다
- 메모를 하는 것이라서 Memorization 이라고 한다
피보나치 수 예제
위 코드의 시간복잡도는 다음과 같이 계산할 수 있다.
기억해야 할 점은 모든 문제를 한번씩 푼다는 것이다.
함수 호출은 n번 일어난다. 여기에 함수의 시간복잡도를 계산해주면 된다. 함수의 시간복잡도는 O(1) 이다.
따라서 위 코드의 시간복잡도는 O(n) 이다.
다이나믹 프로그래밍 구현 방법
- Top-down : 재귀함수를 이용한 방법 (모든 문제를 쪼개놓고 필요할 때 구한다)
- Bottom-up : 작은문제부터 풀고, 조금씩 크게 만들면서 문제를 푼다 (모든 문제를 쪼개놓고 풀어놓는다)
위 방법중 아무거나 쓰면 된다. 정답이 없다.
문제 풀이 전략
맨 처음 먼저 점화식을 세운다!!
이 문제를 구하기 위해서 어떤 작은 문제들이 필요한가??
작은 문제들이 어떻게 원래 문제를 풀어낼 수 있는가??
-
문제를 크기가 작은문제부터 차례대로 푼다
for(int i=0;i<n;i++)
-
문제의 크기를 점점 크게 만들면서 푼다
1로 만들기 (1463번 문제)
이 문제의 경우 3가지의 행동이 가능하다. 문제를 다이나믹 프로그래밍으로 풀기 위해 작은 문제들로 쪼개어 로직을 구성하면 다음과 같다.
- X가 3으로 나누어 떨어지면, 3으로 나눈다 : N->N/3 => D[N] -> D[N/3] + 1
- X가 2로 나누어 떨어지면, 2로 나눈다 : N->N/2 => D[N] -> D[N/2] + 1
- 1을 뺀다 : N->N-1 => D[N] -> D[N-1] + 1
최소의 결과값을 얻기 위해서는 항상 최소의 과정이 필요하다 따라서 D[N] 을 계산하는 과정은 항상 최소가 되어야 한다. 결과적으로 점화식은 다음과 같은 형태를 하게 된다.
D[N] = min(D[N/3]+1,D[N/2]+1,D[N-1]+1) = min(D[N/3],D[N/2],D[N-1]) + 1
이 문제의 시간복잡도는 전체문제의 개수(배열의 크기) * 1문제를 푸는 시간(O(1)) 이므로 O(N) 이다.
코드는 다음과 같다
2Xn 타일링 (11726번 문제)
문제를 점화식으로 파악하는 것이 가장 중요하다.
이 문제에서는 2XN 크기의 타일을 채울 수 있는 경우의 수를 D[N]이라고 잡자. 2X1 타일을 맨 뒤에 붙인다고 생각하면, 2X(N-1) 만큼의 타일이 남는다. 이것은 D[N-1] 이라고 할 수 있다. 또 1X2 타일 두개를 맨 뒤에 붙인다고 생각하면 2X(N-2) 만큼의 타일이 남고, 이것은 D[N-2] 이다.
따라서 점화식은
D[N] = D[N-1] + D[N-2]
라고 할 수 있다.
또한 여기서 D[1] (즉 2X1) 일 때는 무조건 1가지의 경우의 수이기 때문에 D[1] = 1이다. D[2] 는 가로로 두개를 놓거나 세로로 두개를 넣는 경우 딱 두가지로 나오기 때문에 D[2] = 2 이다. D[3] 부터는 D[N] = D[N-1] + D[N-2]
점화식이 적용될 수 있다. 코드를 짜보면 다음과 같다.
2Xn 타일링2 (11727번 문제)
위의 문제와 완벽하게 동일하지만, 추가할 수 있는 2x2 타일이 추가되었다. 2x2 타일이 맨 뒤에 오는 경우를 생각해보면 D[N-2] 와 같다. 따라서 점화식은 D[N] = D[N-1] + 2*D[N-2]
로 나타낼 수 있다. 또 D[1] = 1
은 변하지 않지만, D[2] 는 가로 블럭이 두개 오는경우, 세로 블럭이 두개 오는 경우, 2x2 타일이 한개 오는 경우로 D[2] = 3
으로 나타낼 수 있다.
코드는 다음과 같다
1,2,3 더하기 (9095번 문제)
다이나믹 프로그래밍으로 풀어보자. 먼저 n 을 만들 수 있는 경우의 수를 D[N] 이라고 한다면, 마지막에 1을 더하고 끝난 것은 D[N-1], 2를 더하고 끝난 것은 D[N-2], 3을 더하고 끝난 것은 D[N-3] 이라고 할 수 있다. 그래서 점화식은 D[N] = D[N-1] + D[N-2] + D[N-3]
이라고 할 수 있다. 단 1을 만들 수 있는 경우의 수는 1이므로 D[1] = 1 이고, 2를 만들 수 있는 방법은 D[2] = 2, 3을 만들 수 있는 방법은 (3,2+1,1+2,1+1+1) 4가지 이므로 D[3] = 4 이다.
코드는 다음과 같다
1,2,3 더하기3 (15988번 문제)
위 문제와 똑같으나 범위가 늘어났다. 여기서 중요한 점은 int 형으로는 계산할 수 없기 때문에 long long
을 써야 한다
코드는 다음과 같다
카드 구매하기 (11052번 문제)
이 문제는 다음과 같이 접근할 수 있다. 카드 i 개를 구매하는 최대 비용을 D[i]
라고 한다면, 카드팩 j 를 구매하고 남은 i-j 개의 카드를 고르는 최대 비용은 D[i-j]
이다. 따라서 점화식은 D[i] = P[j] + D[i-j]
라고 할 수 있다. 그런데 여기서 중요한 점은 j 의 범위를 꼭 기억해야 한다는 것이다. 여기서 j 의 범위는 1<=j<=i
이다. j 가 0 이 되는 것은 D[i]=D[i] 로 의미없는 식이 되기 때문에 1부터 시작한다. 이렇게 점화식 안에 또다른 변수가 나타난 경우에는 모든 경우의 수를 다 구해보고, 그 중에 해당하는 값을 골라야 한다. 따라서 완성된 점화식은 다음과 같다.
D[i] = max(P[j] + D[i-j]) where (1<=j<=i)
`
코드는 다음과 같다
카드 구매하기 (11052번 문제)
위의 문제와 완벽하게 동일하나, 최솟값을 구하는 문제이다. 이때 최솟값을 구할 때 초기 값을 -1 로 정하고 만약 -1 이라면 무조건 다음 값을 최솟값으로 만들어 주는 과정만 추가해 주면 된다.
코드는 다음과 같다
1,2,3 더하기 5 (15990번 문제)
이 문제에서는 같은 수를 두번 이상 연속해서 사용하면 안된다. 그렇다면 점화식이 달라지게 된다. 그냥 다이나믹 프로그래밍으로 기존 점화식을 사용하면 안된다.
점화식을 세개로 쪼갠다
D[i][j] = i 를 1,2,3 의 합으로 나타내는 방법의 수, 마지막에 사용한 수는 j
여기서 마지막에 사용한 수라는 것은 i 를 만들기 위해서 사용한 마지막의 바로 전 문자열이라는 것이다. 따라서 j 를 마지막에서 두번째에 사용했기 때문에, 마지막에는 사용할 수 없다.
D[i][1] = D[i-1][2] + D[i-1][3]
D[i][2] = D[i-2][1] + D[i-2][3]
D[i][3] = D[i-3][1] + D[i-3][2]
이 문제에서 점화식만 세우면 큰 어려움은 없는데, 나눈 나머지를 구해야 하므로, 모든 과정에서 %=
를 적용해주고 마지막 결과값 출력에도 %=
를 해주어야 한다.
코드는 다음과 같다
쉬운 계산 수 (10844번 문제)
먼저 점화식을 세우기 위해 규칙을 찾아본다.
D[N] = 길이가 N인 *계단 수*의 개수
(여기서 계단수란 옆자리와의 값이 1씩 차이나는 수) 라고 정의하게 되면 문제가 발생한다. 계단수를 만들기 위해서 옆자리와 비교를 할 수 있어야 하는데, 위 점화식으로는 불가능하다. 따라서 임의의 수를 맨 마지막에 넣고, 그 앞자리에 무엇이 올 수 있는지에 대한 점화식으로 쪼개서 생성해야 한다.
만약 D[N][L]
이 길이가 N인 계산수이고 마지막 숫자가 L 인 식이라고 하자. 그러면 마지막 바로 전 숫자는 L-1 또는 L+1 이 올 수 있다. 따라서 점화식은 다음과 같이 생성된다.(예외처리 포함)
D[N][L] = D[N-1][L-1](L>0) + D[N-1][L+1](L<9)
이에 맞추어 코드를 생성해주면 된다. 예외처리를 잘 생각해야 한다. 맨 앞에는 0이 올 수 없고(D[1][0] 은 없다) 1자리수를 제외한 마지막 자리들에는 0이 들어갈 수 있다.
오르막 수 (11057번 문제)
D[N][L]
을 마지막 숫자가 L인 N자리수 수라고 한다면, 마지막 전 숫자는 L 이하인 모든 수가 올 수 있다. 따라서 점화식은 D[N][L] = D[N-1][K](K<=L)
로 세울 수 있다.
또한 맨 앞자리 수가 0으로 시작할 수 있기 때문에, 항상 K를 0이상 L이하로 생각하고 풀면 된다. 초기화는 D[1][L] = 1
이다.
코드는 다음과 같다
이친수 (2193번 문제)
이친수에서는 0으로 시작하지 않고, 1이 연속으로 나오지 않는다. 이 문제도 전형적인 점화식 쪼개주는 문제이다.
D[N][0] = N자리 이친수인데, 마지막 수가 0인 개수
와 D[N][1] = N자리 이친수인데, 마지막 수가 1인 개수
로 나누어 볼 수 있다. 따라서 점화식은 다음과 같다.
D[N][0] = D[N-1][0] + D[N-1][1]
D[N][1] = D[N-1][0]
0으로 시작하지 않기 때문에, D[1][0] = 0
, D[1][1] = 1
이다.
또한 이 문제는 0,1 만 존재하기 때문에 2차원으로 풀지 않고 1차원으로도 풀 수 있다. 다음과 같이 나타낼 수 있다.
D[i] = D[i-1] // 바로 전에는 모두 다 올 수 있다 (0인경우)
D[i] = D[i-2] // 바로 전에는 0만 올 수 있고, 두번째 전에는 모두 올수 있기 때문이다 (1인경우)
이 문제의 경우에는 우연히 1차원으로 풀 수 있었다. 그러나 모든 문제가 이러한 풀이 방식으로 가능하지는 않기 때문에, 연습할 때는 항상 2차원으로 만들어서 푸는 것을 추천한다!(대부분의 점화식 쪼개기 문젠는 2차원 범위에서 해결이 가능하다.
이 문제의 값은 int 형의 범위를 벗어나기 때문에 long long 을 써 주어야 한다
스티커 (9465번 문제)
이 문제가 다이나믹 프로그래밍이 되는 이유는, 마지막에 뜯는 스티커가 같다고 생각한다면, 그 전까지의 최댓값이 결과의 최댓값과 같기 때문이다.
D[N] = 뗄 수 있는 스티커들의 최댓값
이라고 하자. 그렇다면 최댓값을 구하기 위해서 하나의 스티커를 뜯었을 때, 그 이후의 결과가 어떻게 나오는지 보자.
맨마지막 column (1x2) 스티커를 뜯는 방법은 세가지가 있다.
D[i][j]
를 2xi 에서 얻을 수 있는 최대 점수라고 생각할 때, j 는 이전에 column 에서 어떤 스티커를 떼어냈는지를 나타낸다.
- 아예 뜯지 않는다(j==0) =>
D[i][0] = max(D[i-1][0],D[i-1][1],D[i-1][2])
- 위에만 뜯는다(j==1) =>
D[i][1] = A[i][0] + max(D[i-1][0],D[i-1][2])
- 밑에만 뜯는다(j==2) =>
D[i][2] = A[i][1] + max(D[i-1][0],D[i-1][1])
이 문제는 Top-down 방식의 재귀 함수로 풀게 되면 시간초과가 뜨게 된다. 또한 주의할 점은 2xn 판에서 최댓값을 구하는 것은 n 일때의 D[n][0],D[n][1],D[n][2] 중에서 고르는 것이 아니라 1부터 n까지 모두 중 최댓값을 구해야 한다는 것이다.
코드는 다음과 같다
포도주 시식 (2156번 문제)
연속으로 놓여진 3잔은 마실 수 없다. 이 문제도 경우의 수를 나누어서 생각할 수 있다. D[N][L]
에서 L은 이번(마지막)에 잔을 마시지 않거나(0), 마시는데 이번으로 연속 1번(1)이거나, 이번으로 연속 2번(2)이거나로 나누어서 점화식을 세워본다.
D[N][0] = 전에 연속으로 마시지 않음 = max(D[N-1][0],D[N-1][1],D[N-2][2])
D[N][1] = 이번에 마셔서 1번 연속 = D[N-1][0]
D[N][2] = 이번에 마셔서 2번 연속 = D[N-1][1]
dp 문제(백준)를 시간 초과 없이 풀려면 아무래도 top-down 방식보다 bottom-up 방식이 더 빠른 것 같다. 이제부터 bottom-up 으로 풀려고 한다.
가장 긴 증가하는 부분수열 (11053번 문제)
Longest Increasing Subsequence
수열에서 몇개를 순서대로 빼내어 가장 긴 증가하는 수열을 만드는 문제이다.
수열에서 부분수열을 만드는 방법
2^N 개가 있다. 이 문제는 N<=1000 이기 때문에 2^1000 까지 다 구하는 것은 불가능하다.
다이나믹 프로그래밍으로 푸는 방법
D[i] = A[i]를 마지막으로 하는 가장 긴 증가하는 부분수열
이라고 하자. 그렇다면 그 뒤에 올 수 있는 최대값을 만드는 뒷 부분 수열은 항상 같다. 구하는 방법은 다음과 같다.
A = {10, 20, 10, 30, 20, 50,10}
이라고 하자. 순차적으로 1개부터 N개까지 D[N]
을 구해본다. 여기서 하나의 D[N]
을 구할때는 이전의 D[N-1]
~D[1]
중 만족하는 가장 큰 녀석을 자신에게 더해준다. 따라서 순서는 N-1 부터 1 순으로 찾아간다.
- D[1] 은 스스로 증가하는 부분수열을 만족하기 때문에 1이다.
- D[2] 는 스스로 만족 & D[1] 을 포함하여 만족하므로 1+D[1] = 2 이다.
- D[3] 은 스스로만 만족하므로 1이다.
- D[4] 는 스스로 만족 & D[3] 도 가능하고 D[2] 도 가능하고 D[1]도 가능하다. 이 중 가장 값이 큰 녀석을 저장하기 때문에 3이 저장된다.
- D[5] 는 스스로 & D[3],D[1] 을 만족하므로 2가 저장된다.
- D[6] 은 스스로 & D[5~1] 다되기 때문에 그 중 가장 큰 값을 저장하여 4가 된다.
- D[7]은 스스로만 만족하므로 1이 저장된다.
D[] = {1,2,1,3,2,4,1}
이 LIS 문제에서 정답은 4가 된다. 이 문제에서 가장 큰 D[N] 이 정답이 된다.
코드는 다음과 같다
가장 긴 증가하는 부분 수열4 (14002)
위 문제와 정확하게 동일하게 가장 긴 증가하는 부분 수열을 찾는 문제이지만, 그 긴 수열 자체를 출력하는 문제이다.
이 문제에서는 앞에서 한 과정에 기록하는 과정을 추가해야 한다. 앞의 D[i]
에 의해 현재 D[N]
이 바뀔 때, V[N]
에는 i (인덱스) 를 기록해주어 어떤 D[i]
에 의해 영향을 받았는지 기록해준다.
인덱스를 보고 쭉쭉 거꾸로 따라가다 보면(V[i] 가 0이 나올때까지), 앞의 모든 수열들을 찾아낼 수 있다. 찾아낼 때 재귀함수를 이용하면 순서대로 나오고, 그냥 반복으로 찾아주면 거꾸로 나오기 때문에 뒤집어 줘야 한다.
코드는 다음과 같다
가장 큰 증가하는 부분 수열 (11055번 문제)
이 문제는 다 똑같은데 개수 대신 합 이 가장 큰 수열을 고르는 문제이다. 간단하게 개수를 구하는 부분을 합을 구하도록 바꾸기만 하면 된다.
코드는 다음과 같다
가장 긴 감소하는 부분 수열 (11722번 문제)
먼저 A[i] 에서 시작하는 가장 긴 감소하는 부분 수열을 구하는 방식으로 풀 수 있다. 또는 더 쉬운 방법으로는 뒤집어서 가장 긴 증가하는 부분 수열을 찾고, 다시 뒤집으면 된다.
처음에 받을 때만 반대로 받고 가장 긴 증가하는 부분 수열의 개수를 찾아주면 된다.
가장 긴 바이토닉 부분 수열 (11054번 문제)
가장 긴 증가하는 부분수열(D1)과 가장 긴 감소하는 부분 수열(D2)을 구한다. 결과는 D1 + D2 - 1 이 된다.
- 증가하는 부분수열의 개수를 모두 찾는다
- 감소하는 부분수열의 개수를 모두 찾는다 (뒤집어서 했음)
- 두개를 합친다음 1씩 빼서 저장한 것 중 최댓값을 찾는다
코드는 다음과 같다
연속합 (1912번 문제)
연속된 숫자들을 뽑아서 가장 큰 합을 구하는 문제이다.
각각의 수가 연속합에 포함되었을 때의 최댓값을 찾는 방식으로 풀어나가면 된다.
D[i] = A[i] 에서 끝나는 연속합의 최대
라고 하자. 즉 D[i] = A[i] + D[i-1]
이라고 생각할 수 있다. 아니면 자기 자신 혼자만 존재하는 D[i] = A[i]
라고 할 수도 있다. 따라서 점화식은 D[i] = max(A[i],A[i]+D[i-1])
이라고 할 수 있다.
코드는 다음과 같다
연속합2 (13398번 문제)
이 문제는 위의 문제와 완벽하게 동일하지만 수1개를 제거할 수 있다. 그냥 쉽게 제거할 수를 하나 선택하고 제거한 다음 위와 같이 구하면 O(백만) 나와서 시간안에 절대로 못푼다!
이 문제는 하나의 제거할 대상을 두고 오른쪽에서부터 dr 을 구하고 왼쪽에서부터 dl 을 구하는 방식으로 풀어나가야 한다. 결과값은 dr 과 dl 을 합친 값이 된다.
- 먼저 일반적인 방식으로 왼쪽에서부터 모든 부분합의 값을 구한다.
- 오른쪽부터 시작해서 구하는 부분합을 구해놓는다.
- 일반적인 부분합과, 하나를 제거하고 왼쪽과 오른쪽을 연결한 부분합 중 가장 큰 값을 결과값에 넣고 출력한다.
코드는 다음과 같다
제곱수의 합 (1699번 문제)
주어진 N을 제곱수들의 합으로 표현할 때 그 항의 최소개수를 구하는 문제이다.
여기서 D[N]
은 항의 개수이다. 그런데 더해진 마지막 수가 뭔지를 알 수가 없다. 편의상 i^2
이라고 한다면, D[N] = min(D[N-i^2] +1)
이라고 할 수 있다. 여기서 i^2 은 1보다 커야하고, N보다 작아야 한다. 따라서 점화식은 다음과 같다.
D[N] = min(D[N-i^2] +1) (1<=i^2<=N)
코드는 다음과 같다
합분해 (2225번 문제)
0~N까지 정수 K개 더해서 그 합이 N이 되는 경우의 수를 구하는 것이다.
D[K][N] = N까지의 정수 K개를 더해서 N을 만드는 경우의 수
라고 하자. 맨 뒤에 더해진 값이 L이라고 한다면, D[K][N] = sum(D[K-1][L] + 1) (0<=L<=N)
이다.
이 방법은 N^3 의 시간 복잡도가 나오게 되는데, 더 빠르게 풀 수 있는 방법이 있다. 이 점화식을 그림으로 그려보고 D[K][N]
을 파악해보면 다음과 같다.
다음과 같이 D[K][N] = D[K][N-1] + D[K-1][N]
이 된다.
코드는 다음과 같다
Comments