자. 삼각형을 살짝 바꾸자.

        7
      3   8
    8   1   0
  2   7   4   4
4   5   2   6   5

이 삼각형을

7
3   8
8   1   0
2   7   4   4
4   5   2   6   5

이렇게. 풀기 훨씬 쉬워졌다.

 

dp[n][k]는 (0,0)에서 (n, k)까지 가면서 선택된 수들의 최대 합이라고 하자.

n-1번째 행에서 (n, k)로 올 수 있는 좌표는 (n-1, k-1)와 (n-1, k)이다.

dp[n][k]는 최댓값을 저장해야하기 때문에 dp[n-1][k-1]와 dp[n-1][k] 중 더 큰 값을 선택하고 (n, k) 좌표에 저장된 수를 더해주면 된다.

 

결국 점화식은

dp[n][k] = max(dp[n-1][k-1], dp[n-1][k]) + num[n][k]

가 된다.

 

이제 코딩하자.

어.. 근데.. n이 0부터 시작하게 되면 n-1가 음수가 안되도록 따로 처리해주어야 한다.. k-1도 마찬가지.

귀찮다. 삼각형을 다시 한번 바꿔보자

0
0   7
0   3   8
0   8   1   0
0   2   7   4   4
0   4   5   2   6   5

이제 (0, 0)이 아니라 (1, 1)부터 시작하자.

이렇게 해도 문제가 없을까 ? 없다.

최댓값을 찾는 문제이기 때문에 가장 앞에 있는 0이 선택 될 일은 없다.

진짜 코딩하자.

#include <bits/stdc++.h>
using namespace std;

int dp[502][502];
int num[502][502];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);

	int n;
	cin >> n;
	for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) {
		cin >> num[i][j];
	}

	for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) {
		dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + num[i][j];
	}
	
	// 최댓값을 찾자.
	int ans = 0;
	for (int i = 1; i <= n; ++i) ans = max(ans, dp[n][i]);
	
	cout << ans;

	return 0;
}

 

위에 코드를 보면 음.. num이 필요없어 보인다. 살짝 최적화를 해보자.

#include <bits/stdc++.h>
using namespace std;

int dp[502][502];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);

	int n;
	cin >> n;
	for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) {
		cin >> dp[i][j];
	}

	for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) {
		dp[i][j] += max(dp[i - 1][j - 1], dp[i - 1][j]);
	}
	
	int ans = 0;
	for (int i = 1; i <= n; ++i) ans = max(ans, dp[n][i]);
	
	cout << ans;

	return 0;
}

 

세우자. 점화식.

 

비용의 최솟값을 구하는게 목적이다.

 

확실하게 할 것이 하나있다.

우리는 1~n번째 집들이 각각 어떤 색으로 칠해지는지 궁금하지 않다.

얼마의 비용이 필요한지 궁금하다.

 

그러므로, 이 문제에선 우리는 세 개의 DP 배열이 필요하다.

빨간색을 선택했을 때 총 비용의 최솟값, 초록색을 선택했을 때 총 비용의 최솟값, 파란색을 선택했을 때 총 비용의 최솟값을 저장할 배열이다.

 

자세하게 설명하면, dp[n][빨간색]의 값은 n번째 집을 빨간색으로 칠했을 때 총 비용의 최솟값이라고 하자.

그렇다면, n번째 집을 빨간색으로 칠할 때 총 비용의 최솟값은 n번째 집을 빨간색으로 칠하는 비용 + (n-1번째 집이 초록색일 때 총 비용의 최솟값)과 (n-1번째 집이 파란색일 때 총 비용의 최솟값) 중 작은 값이라고 봐도 될까 ? 

 

식으로 표현하게 되면, 

dp[n][빨간색] = min(dp[n-1][초록색], dp[n-1][파란색]) + cost[n][빨간색]

이라고 해도 될까 ?

 

된다.

우리는 n번 집을 빨간색으로 칠할 때, 1 ~ n-2번이 어떤 색으로 칠해져 있는지 궁금하지 않다.

그저 n-1번째 집이 초록색 또는 파란색일 때 드는 전체 비용의 최솟값만 알면 n번 집을 빨간색으로 칠할 때 총 비용의 최솟값을 알 수 있다.

 

결국 점화식을

dp[n][빨간색] = min(dp[n-1][초록색], dp[n-1][파란색]) + cost[n][빨간색]

dp[n][초록색] = min(dp[n-1][빨간색], dp[n-1][파란색]) + cost[n][초록색]

dp[n][파란색] = min(dp[n-1][빨간색], dp[n-1][초록색]) + cost[n][파란색]

으로 정리할 수 있다.

 

자, 밑의 예제를 통해 세 개의 DP배열을 어떻게 사용하는지 알아보자.

  R G B
1 26 40 83
2 49 60 57
3 13 89 99

각각의 집을 색칠할 때마다 드는 비용이 위와 같다고 하자.

1번 집은 첫번째 집이기 때문에 어떤 색으로 칠하든 그 값이 곧 총 비용의 최솟값이 된다. DP의 첫번째 행을 업데이트 하자.

  R G B
dp[1] 26 40 83
dp[2]      
dp[3]      

2번 집을 빨간색으로 칠하려고 한다. 규칙에 의해 1번 집은 초록색 or 파란색이 되어야 한다.

때문에 dp[2][R]은 dp[1][G]과 dp[1][B] 중 더 작은 값 + 2번 집을 빨간색으로 칠하는 비용이다.

 

마찬가지로 2번 집을 초록색인 경우(dp[2][G])는 dp[1][R]과 dp[1][B] 중 더 작은 값 + 2번 집을 초록색을 칠하는 비용이다.

 

2번 행을 채우자.

  R G B
dp[1] 26 40 83
dp[2] 89(=49 + 40) 86(=26+60) 83(=26+57)
dp[3]      

 

3번 집을 칠해보자.

우리는 2번 집을 초록색으로 칠했을 때 총 비용의 최솟값(dp[2][G])과 파란색으로 칠했을 때 총 비용의 최솟값(dp[2][B])을 알고 있다.

따라서 3번 집을 빨간색으로 칠할 때 총 비용의 최솟값은 dp[2][G]와 dp[2][B] 중 더 작은 값 + 3번 집을 빨간색으로 칠하는 비용이다.

같은 매커니즘으로 초록색과 파란색의 경우도 구하자.

  R G B
dp[1] 26 40 83
dp[2] 89 86 83
dp[3] 96(=83 + 13) 172(=83+89) 185(=86+99)

결국 1, 2, 3번 집을 칠하는데 드는 총 비용의 최솟값은 96, 172, 185 중 가장 작은 값인 96이 된다.

 

이해가 안될 수도 있다. 나도 그랬다.

dp[n][R]의 의미를 n번째 집을 빨간색으로 칠할 때 전체 비용의 최솟값이라고 설정했을 때,

dp[n-1][R], dp[n-1][G], dp[n-1][B]의 의미를 생각하고 이것들을 이용하여 dp[n][R]을 만드는 연습을 해보자.

 

이 문제는 n-1번째만 보면 되지만, n-2번째나 n-3번째를 봐야하는 경우도 있으니.. 점화식 만드는 연습을 하자.

 

이해됐으면 코딩하자.

#include <bits/stdc++.h>

using namespace std;

// 0 : R, 1 : G, 2 : B
int cost[1001][3];
int dp[1001][3];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);

	int n;
	cin >> n;

	for (int i = 1; i <= n; i++) cin >> cost[i][0] >> cost[i][1] >> cost[i][2];

	for (int i = 1; i <= n; i++) {
		dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + cost[i][0];
		dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + cost[i][1];
		dp[i][2] = min(dp[i - 1][0], dp[i - 1][1]) + cost[i][2];
	}

	cout << min({ dp[n][0],dp[n][1],dp[n][2] });

	return 0;
}

아무 생각 없이 int형 배열로 통과하긴 했는데.. 만약 틀렸을 때는 long long 타입으로 바꿔야하는지부터 의심해보자.

대놓고 점화식 세우라고 힌트 준 DP문제이다.

한 삼각형의 변이 어떤 규칙에 의해 정해지는지 확인해보자.

1, 1, 1, 2, 2, 3, 4, 5, 6, 7, 9

이 문제를 해결할 때, 난 6번째 삼각형부터 확실한 규칙이 보였다.

6번째부터 파란색 삼각형의 한 변의 길이는 두 개의 흰색 삼각형 변의 길이의 합이고, 흰색 삼각형의 한변의 길이는 두 개의 파란색 변의 길이의 합인 것을 봤다.

 

다시 눈을 크게 뜨고 보자.

삼각형의 변의 길이를 결정하는 두 개의 삼각형 중 하나는 무조건 바로 전에 만들어진 삼각형이고, 다른 하나는 5번째 전에 만들어진 삼각형이다.

 

결국 점화식은 \(a_n = a_{n-1} + a_{n-5}\) 이 된다. 

코딩 하자 !

아 참.. 이 문제는 n이 100까지라 int형 범위를 초과할 수 있다.

사실 나도 배열을 int형으로 선언하고 틀렸습니다를 받은 다음에 범위를 초과할 수 있다는 것을 캐치했다.

그러니.. 배열을 long long 타입으로 선언하자.

#include <bits/stdc++.h>
using namespace std;

long long dp[101];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);
	
	dp[1] = 1;
	dp[2] = 1;
	dp[3] = 1;
	dp[4] = 2;
	dp[5] = 2;

	for (int i = 6; i < 101; i++) dp[i] = (dp[i - 1] + dp[i - 5]);

	int T, n;
	cin >> T;
	while (T--) {
		cin >> n;
		cout << dp[n] << '\n';
	}


	return 0;
}

 

 

 

자.. 나왔다. 큰 수로 나눈 나머지를 출력하라는 문제.

지금이야 DP문제인 것을 알고 풀지만, 이 문제처럼 어떤 수로 나눈 나머지를 출력하는 문제는 DP로 풀릴 확률이 매우 높다.

 

1과 00을 갖고 N자리의 이진 수열의 개수를 구하는 것이 목표이다.

잘 모르겠으면 일단 적어보자. 어떤 "규칙"을 발견할 지도 모르니...

 

1자리 : 1 - 1개

2자리 : 00, 11 - 2개

3자리 : 001, 100, 111- 3개

4자리 : 0000, 0011, 1001,  1100, 1111- 5개

5자리 : 00001, 00100, 00111, 10000, 10011, 11001, 11100, 11111 - 8개

6자리 : 000000, 000011, 001001, 001100, 001111, 100001, 100100, 100111, 110000, 110011, 111001, 111100, 111111 - 13개

 

1, 2, 3, 5, 8, 13 ... ? 익숙하다. 앞에 1이 빠진 피보나치 수열이다.

피보나치인 거 발견했으니까 점화식을 갖고 코딩하면 끝이긴 하지만.. 뒤가 좀 구리다.

정말 정말 정말 우연히 6자리까지 피보나치 수열을 만족하고 7자리부터 다른 수가 나올 수도 있지 않나 ?

당신의 생각이 맞다. 그래서 "" 피보나치인지 알아보자.

 

N자리의 모든 이진 수열은 1 또는 00으로 시작한다.

다시 말하면, 1 또는 00 뒤에 1 또는 00을 붙여 N자리의 이진수를 만드는 것이다.

또 다시 말하면, N자리의 이진수를 만들기 위해서 N-1자리 이진수 뒤에 1을 붙이거나 N-2자리 이진수 뒤에 00을 붙이면 된다.

그렇기 때문에 \(a_n\)을 n자리의 이진수라고 하면, \(a_n = a_{n-1} + a_{n-2}\) 이라는 점화식을 만들 수 있다.

 

어 근데 N-1자리 이진수 뒤에 1을 붙인 수와 N-2자리 이진수 뒤에 00을 붙인 수가 같은 경우는 생각 안하나요 ?

네. 그런 경우는 나올 수 없습니다.

 

이유를 수학적으로 풀어보자.

이진수 뒤에 1을 붙이는 행위 = 십진수 x 2 + 1

이진수 뒤에 00을 붙이는 행위 = 십진수 x 4

 

위에 두 식이 이해가 안된다면 직접 한번 이진수 100에 1이나 00을 붙이고 십진수로 표현해보면 된다.

깨알 상식으로 이진수를 왼쪽으로 한번 Shift하는 것은 곱하기 2를 해주는 것과 같은 효과임을 생각해주면 된다.

(그래서 고인물들이 가끔 더 빠른 연산을 위해 x * 2 보다 x << 1을 선호하는 경우도 있다.)

 

자 그럼 여기 어떤 정수 \(x, y\)가 있다.

\(x\)에는 1을 붙이고 \(y\)에는 00을 붙이면, 각각 \(2x + 1, 4y\)가 된다.

이제 우리는 \(2x + 1 = 4y\)를 만족시키는 정수 \(x, y\)를 찾아야 한다. 그래야 같은 수가 나올 수 있다는 것이 증명된다.

식을 \(x\)에 대해 살짝 정리하자.

\(x = 2y - \frac{1}{2}\)

음... \(y\)에 어떤 정수를 넣든 \(-\frac{1}{2}\)에 의해 \(x\)는 정수가 나올 수 없음을 알아버렸다.

 

결국, 우리는 사전에 생각해놨던 피보나치 수열로 이 문제를 해결하면 된다 !

단, 15746으로 나눈 나머지를 출력해야한다는 것을 유의하면서.

여기서 15746으로 나눈 나머지는 또 어떻게 처리하지?라고 생각할 수도 있는데, 정수론에 의해 그냥 모든 결과를 15746으로 나눈 나머지로 저장해주면 된다 ㅎㅎ

코드로 한번 보자.

#include <bits/stdc++.h>
using namespace std;

int fibo[1000001];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);
	
	int n;
	cin >> n;

	fibo[1] = 1;
	fibo[2] = 2;
	// 매번 15746으로 나눈 나머지를 저장
	for (int i = 3; i <= n; i++) fibo[i] = (fibo[i - 1] + fibo[i - 2]) % 15746;

	cout << fibo[n];

	return 0;
}

문제에서 대놓고 점화식을 줬다. 천사같은 문제라고 볼 수 있다.

고민해야될 것은 Top-Down으로 할 지, Bottom-Up으로 할 지이다.

 

그냥 둘 다 해보자.

 

1. Top-Down문제에서 대놓고 그대로 구현하면 값을 구하는데 오래 걸린다고 한다.DP 1번 문제였던 피보나치와 마찬가지로 너무 많은 재귀함수가 호출되기 때문이다.그러면.. 해결방법은 재귀함수 호출을 줄이면 된다. 뭘 이용해서 ? Memorization !

 

이 문제에선 배열에 저장된 값이 0이 될 수 있기 때문에, 처음에 배열에 모든 값을 -1로 초기화해주자.

 

왜 ?

 

예를 들어, w[20][20][20]을 구하기 위해서 w[20][20][19]에 접근했더니 w[20][20][19]에 저장된 값이 0이라고 가정해보자.

재귀함수를 통해 이미 계산된 값이라면 더 돌아볼 것도 없이 0을 리턴해주면 되지만, 초기화된 값이었다면 w[20][20][19] 값을 구하기 위해 또 다른 재귀함수가 실행될 것이다.

자... 이 저장된 0은 재귀함수를 통해 이미 계산된 값인지 프로그램 실행 때 초기화된 값인지.. 어떻게 알 것인가 ? 모른다;; 그렇기 때문에, 아직 계산되지 않은 원소라는 의미에서 배열 안에 모든 원소를 -1로 초기화시켜주자.

 

이제 재귀함수만 작성하면 끝난다.재귀함수 내에서 w[a][b][c]에 저장된 값이 -1인지 체크해보자. -1이라면 문제에서 준 점화식을 통해 값을 계산하면 될 것이고, -1 아니라면 이미 계산된 값이기 때문에 그 값을 바로 리턴하자.

 

그럼 끝! 이 아니다. a, b, c 중 하나라도 0보다 작거나 같은 경우와 20보다 큰 경우는 따로 처리해주어야하기 때문에, w[a][b][c]에 먼저 접근하지 말고 a, b, c의 값을 먼저 체크 해주어야 한다. 

 

무슨 소리인지 모르겠다면 코드로 한번 보자.

#include <bits/stdc++.h>
using namespace std;

int w[21][21][21];

int getW(int a, int b, int c) {
	// a, b, c 범위 체크
	if (a <= 0 || b <= 0 || c <= 0) return 1;
	if (a > 20 || b > 20 || c > 20) return getW(20, 20, 20);
	
	// 이미 계산된 a b c인지 확인
	// 계산된 게 맞다면 바로 저장된 값 리턴
	if (w[a][b][c] != -1) return w[a][b][c];
    
	// 아니라면 점화식 수행
	if (a < b && b < c) return w[a][b][c] = getW(a, b, c - 1) + getW(a, b - 1, c - 1) - getW(a, b - 1, c);
	else return w[a][b][c] = getW(a - 1, b, c) + getW(a - 1, b - 1, c) + getW(a - 1, b, c - 1) - getW(a - 1, b - 1, c - 1);

	return -1;
}

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);
	// 배열 안에 모든 값 -1로 초기화, for문을 사용하는 것보다 빠름
	memset(w, -1, sizeof(w));

	int a, b, c;
	while (true) {
		cin >> a >> b >> c;

		if (a == -1 && b == -1 && c == -1) break;

		cout << "w(" << a << ", " << b << ", " << c << ") = " << getW(a, b, c) << '\n';
	}

	return 0;
}

2. Bottom-Up

점화식이 있을 때 Bottom-Up방식 구현은 너무나도 쉽다.그냥 for문을 통해 구현해서 배열w를 채우자 !

#include <bits/stdc++.h>
using namespace std;

int w[21][21][21];

int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);
    
	for (int a = 0; a < 21; a++)
		for (int b = 0; b < 21; b++)
			for (int c = 0; c < 21; c++) {
				// a, b, c가 20을 넘는 경우는 없기 때문에, 0보다 작거나 같은 경우만 신경쓰자.
				if (a <= 0 || b <= 0 || c <= 0){
					w[a][b][c] = 1;
				}
				else if (a < b && b < c){
					w[a][b][c] = w[a][b][c - 1] + w[a][b - 1][c - 1] - w[a][b - 1][c];
				}
				else {
					w[a][b][c] = w[a - 1][b][c] + w[a - 1][b - 1][c] + w[a - 1][b][c - 1] - w[a - 1][b - 1][c - 1];
				}
			}
				

	int a, b, c, result;
	while (true) {
		cin >> a >> b >> c;

		if (a == -1 && b == -1 && c == -1) break;

		if (a <= 0 || b <= 0 || c <= 0) result = 1;
		else if (a > 20 || b > 20 || c > 20) result = w[20][20][20];
		else result = w[a][b][c];

		cout << "w(" << a << ", " << b << ", " << c << ") = " << result << '\n';
	}

	return 0;
}

이 문제에 나온 코드 그대로 적어서 내면 시간초과 나온다.

왜 그런지 알아보자.

 

fibonacci(10)을 호출했다고 가정해보자.

fibonacci(10) -> 1 * fibonacci(9) + 1 * fibonacci(8)

fibonacci(10) -> fibonacci(8) + fibonacci(7) + fibonacci(7) + fibonacci(6)

                              = 1 * fibonacci(8) + 2 * fibonacci(7) + 1 * fibonacci(6)

fibonacci(10) -> fibonacci(7) + fibonacci(6) + 2 * (fibonacci(6) + fibonacci(5)) + fibonacci(5) + fibonacci(4)

                             = 1 * fibonacci(7) + 3 * fibonacci(6) +  3 * fibonacci(5) + 1 * fibonacci(4)

 

대략 이런 식으로 계속 호출이 일어난다. 

혹시, 붉은색 숫자들을 보고 떠오르는 것이 없나 ?

나는 이게 떠올랐다.

파스칼 삼각형

결국, fibonacci(10)을 호출하면 재귀 호출에 의해 함수들의 총 호출 횟수가 \((1 + 9 + 36 + 84 + ... + 36 + 9 + 1)\) 이 될 것이다.

일반화해보자.

\(n > 0\)일 때, fibonacci(n)을 호출하면 총 함수 호출 횟수는 \(_{n - 1}C_0 + _{n - 1}C_1 + ... + _{n - 1}C_{n - 2} + _{n - 1}C_{n - 1}\)이 된다.

또한 \(_{n - 1}C_0 + _{n - 1}C_1 + ... + _{n - 1}C_{n - 2} + _{n - 1}C_{n - 1} = 2^{n - 1}\) 임을 우린 알고 있다.

따라서, 문제에서 제공하는 소스의 시간복잡도는 \(O(2^{n})\)이다.

ㅋㅋ 지수승의 시간복잡도는.. 알고리즘 문제에서 절대 피해야하는 친구이다.


시간복잡도를 개선해보기 전에, 사실 이 문제.. 설명이 좀 틀린 부분이 있다. 

호출 순서에 대한 것인데, 

  • fibonacci(3)은 fibonacci(2)와 fibonacci(1) (첫 번째 호출)을 호출한다.
  • fibonacci(2)는 fibonacci(1) (두 번째 호출)과 fibonacci(0)을 호출한다.

이게 아니라

  • fibonacci(3)은 fibonacci(2)을 호출한다.
  • fibonacci(2)는 fibonacci(1) (첫번째 호출)을 호출한다.
  • fibonacci(1) 이 1 출력 후, 리턴한다.
  • fibonacci(2)가 fibonacci(0)을 호출한다.
  • fibonacci(0)이 0 출력 후, 리턴한다.
  • fibonacci(2)가 1을 출력 후, 리턴한다.
  • fibonacci(3)이 fibonacci(1) (두번째 호출)을 호출한다.
  • fibonacci(1) 이 1 출력 후, 리턴한다.
  • fibonacci(2)가 fibonacci(0)을 호출한다.

이렇게 된다.

왜 WHY? 함수 하나가 동시에 두 개의 재귀함수를 호출할 수 없다. (멀티 쓰레드 프로그램에서는 가능.. 하지만 여기선 논외)

따라서, fibonacci(3)은 fibonacci(2)를 먼저 호출해서 모든 것을 다 처리하고 나서야 fibonacci(1)을 호출할 수 있다.

실제로 직접 코딩해서 출력해보면,

fibonacci(3)이 fibonacci(1)을 호출하는 게 가장 느린 것을 확인할 수 있다.

 

자, 그래서 뭐가 문제냐 ? 

fibonacci(n)을 호출하면, fibonacci(n - 1)을 먼저 호출해서 모든 것을 처리한 이후에 fibonacci(n - 2)를 호출한다.

생각해보자. fibonacci(n - 1)을 처리하면서 이미 fibonacci(n - 2)가 처리됐을 것이다.

근데 한번 더 finbonacci(n - 2)를 처리하는 건 너무 비효율적이라는 생각이 든다.

 

이 때 필요한게, Memorization이다.

먼저, 1 출력 횟수를 구해보자.

\(n == 0\)일 때, 0

\(n == 1\)일 때, 1

\(n == 2\)일 때, 1

\(n == 3\)일 때, 2

...

이 값들을 미리 어딘가에 저장해놓자.

예를 들어 fibonacci(5)의 값은 fibonacci(5) = fibonacci(4) + fibonacci(3)이 되고,

fibonacci(4)를 구하기 위해 fibonacci(3), fibonacci(2), fibonacci(1), fibonacci(0)을 계산할 것이다.

미리 계산해놓은 것이 있다면, fibonacci(3)을 한방에 구하여 바로 fibonacci(5)의 값을 구할 수 있다.

 

백문이 불여일견, 피보나치 수를 구하는 코드를 Memorization을 사용하여 구현한 코드를 한번 보자.

#include <bits/stdc++.h>
using namespace std;

int fibo[41];

int fibonacci(int n) {

	if (fibo[n] != -1) return fibo[n];

	if (n == 0) return fibo[0] = 0;
	else if (n == 1) return fibo[1] = 1;
	else return fibo[n] = fibonacci(n - 1) + fibonacci(n - 2);
	
}
int main() {
	for (int i = 0; i < 41; ++i) fibo[i] = -1;
	fibonacci(40);
    
	return 0;
}

 요로코롬 코딩하면 fibo의 모든 원소들이 채워질 것이다 !

혹시.. DP의 첫번째 글에서 DP에 Top-Down방식과 Bottom-Up방식이 있다라고 얘기했던 것을 기억하는가 ?

설명이 길었지만 이게 재귀를 이용한 Top-Down 방식이다. 

한마디로, 40(꼭대기)에서 시작해서 밑으로 내려가기 때문에 이렇게 이름을 붙인 것 같다.

 

그렇다면 반대로 Bottom-Up은 ? 당연히 0부터 시작해서 위로 올라가는 것이다.

운이 좋게도 우리는 피보나치의 수의 점화식을 알고 있다.

n번째 수는 n - 1번째 수와 n - 2번째 수의 합이라는 아주 아름다운 점화식 말이다.

이걸 그대로 for문을 사용해 구현하여 40번째 피보나치 수까지 구해보자.

#include <bits/stdc++.h>
using namespace std;

int fibo[41];

int main() {
	fibo[0] = 0;
	fibo[1] = 1;
	for(int i = 2; i < 41; ++i) fibo[i] = fibo[i - 1] + fibo[i - 2];
    
	return 0;
}

fibo행렬 채우기 끝!


Memorization이 뭔지 알았으니, 0 출력 횟수와 1 출력 횟수를 담을 배열이 두개 필요하다 ? 사실 아니다.

0 출력 횟수는 0부터 시작했을 때 나오는 피보나치 수이고,

1 출력 횟수는 1부터 시작했을 때 나오는 피보나치 수이다.

0부터 시작했을 때 : 0, 1, 1, 2, 3, 5, 8, 13, ...

1부터 시작했을 때 : 1, 1, 2, 3, 5, 8, 13, 21, ...

 

따라서, fibonacci(n)의

0 출력 횟수 : 0부터 시작했을 때 n번째 피보나치 수

1 출력 횟수 : 0부터 시작했을 때 (n + 1)번째 피보나치 수

가 된다.

아, 한가지 예외 케이스가 있는데 \(n == 0\)일 때는 따로 0 1을 출력하게 처리해주어야한다.

 

정답코드

#include <bits/stdc++.h>
using namespace std;

int fibo[41];

int fibonacci(int n) {
	if (fibo[n] != -1) return fibo[n];

	if (n == 0) return fibo[0] = 0;
	else if (n == 1) return fibo[1] = 1;
	else return fibo[n] = fibonacci(n - 1) + fibonacci(n - 2);
	
}
int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);
    
	for (int i = 0; i < 41; ++i) fibo[i] = -1;
	
	int cnt;
	cin >> cnt;
	while (cnt--) {
		int num;
		cin >> num;
		if (num == 0) cout << 1 << ' ' << 0 << '\n';
		else cout << fibonacci(num - 1) << ' ' << fibonacci(num) << '\n';
	}
    
	return 0;
}

 

내가 가장 못하는 DP가 나왔다.

 

동적 계획법의 의의는

  1. 점화식을 통해 최적화된 풀이 가능
  2. Memorization을 사용하여, 답들을 미리 계산

정도로 볼 수 있다.

 

개인적으로 점화식을.. 정말 못 세우는 편이라 난이도 있는 DP문제를 많이 버거워한다.

 

DP문제 풀이의 감을 잡으면 DP만큼 쉬운 문제가 없다고들 하는데, 나는 감이 아직도 안온다;;

 

앞으로 단순 점화식만 소개하는 것이 아니라, 어떻게 그 점화식을 떠올리게 되었는지도 적을 예정이니 재미삼아 읽어보는 것도 좋을 것 같다.

 

DP를 구현하는 방법은 크게 두 가지가 있다. 재귀를 이용한 Top-Down 방식과 for문을 이용한 Bottom-Up방식이 있다.자세한 내용은 동적 계획법1의 첫번째 문제인 피보나치 문제에서 설명해보겠다.

 

아 ! 한가지 Tip을 드리자면, 가끔 문제를 풀 때 \(1,000,000,009\)같이 매우 큰 소수로 나눈 나머지를 답으로 내라는 문제들이 있다.이럴 땐, 꼭!!!! DP나 Memorization쪽을 고려해보자. 왜냐하면, 저렇게 큰 소수로 나눈 나머지가 답으로 나오는 문제들은 적어도 저 소수보다 많은 경우가 나온다는 말인데... 그 경우를 하나하나 세면 무조건 시간초과일 것이다.

 

두 팀으로 나누기만 하면 되는 문제다.

스타트 팀이 될 사람만 체크하고, 스타트 팀과 링크 팀이 완성되면 두 팀의 전력차를 계산하자.

 

여기서, 한가지 재미있는 점은 4명이 있다고 가정했을 때,

  1. 스타트 팀 : (0 ,1), 링크 팀 : (2, 3)
  2. 스타트 팀 : (2, 3), 링크 팀 : (0, 1)

이 두 가지 경우는 사실상 같은 경우이다.

왜냐하면, (0, 1)이 스타트 팀인지 링크 팀인지 상관없다. 그냥 (0, 1)이 같은 팀이기만 하면 되기 때문이다.

이 사실을 이용하면 시간 복잡도를 줄일 수 있다.

 

1. naive하게 구현

#include <bits/stdc++.h>
using namespace std;

const int INF = 2147483647;

int n;
bool isStartTeam[21]; // 어떤 사람이 스타트 팀이지 체크
int sTeam[21], lTeam[21]; // 각 팀의 팀원 목록
int power[21][21]; // 능력치 저장
int ans = INF; // 답

// cur은 몇번부터 뽑을 차례인지, cnt은 몇명을 뽑았는지
void recur(int cur, int cnt) {

	if (cnt == n / 2) {
		int curStart = 0, curLine = 0;
		// 스타트팀과 링크팀을 나눔.
		for (int i = 0; i < n; ++i) {
			if (isStartTeam[i]) sTeam[curStart++] = i;
			else lTeam[curLine++] = i;
		}
		// 전력 계산
		int sPower = 0, lPower = 0;
		for (int i = 0; i < n / 2; ++i) for (int j = 0; j < n / 2; ++j) {
			sPower += power[sTeam[i]][sTeam[j]];
			lPower += power[lTeam[i]][lTeam[j]];
		}
		
		// 답 갱신
		ans = min(ans, abs(sPower - lPower));
		
		return;
	}

	for (int i = cur; i < n; ++i) {
		isStartTeam[i] = true;
		
		recur(i + 1, cnt + 1);

		isStartTeam[i] = false;
	}
}


int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);

	cin >> n;
	for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) cin >> power[i][j];

	recur(0, 0);

	cout << ans;
	return 0;
}

 

2. 같은 경우를 제거

#include <bits/stdc++.h>
using namespace std;
const int INF = 2147483647;

int n;
bool isStartTeam[21];
int sTeam[21], lTeam[21];
int power[21][21];
int ans = INF;

void recur(int cur, int cnt) {

	if (cnt == n / 2) {
		int curStart = 0, curLine = 0;

		for (int i = 0; i < n; ++i) {
			if (isStartTeam[i]) sTeam[curStart++] = i;
			else lTeam[curLine++] = i;
		}

		int sPower = 0, lPower = 0;
		for (int i = 0; i < n / 2; ++i) for (int j = 0; j < n / 2; ++j) {
			sPower += power[sTeam[i]][sTeam[j]];
			lPower += power[lTeam[i]][lTeam[j]];
		}

		ans = min(ans, abs(sPower - lPower));

		return;
	}
	
	// for문의 범위가 바뀜
	for (int i = cur; i < n / 2 + cnt; ++i) {
		isStartTeam[i] = true;

		recur(i + 1, cnt + 1);

		isStartTeam[i] = false;
	}
}


int main() {
	ios::ios_base::sync_with_stdio(false);
	cin.tie(nullptr);
	cout.tie(nullptr);

	cin >> n;
	for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) cin >> power[i][j];

	recur(0, 0);

	cout << ans;
	return 0;
}

확실히 빨라졌다.

'BOJ_단계별로 풀어보기(9단계~) > [13단계] 백트래킹' 카테고리의 다른 글

[백준 14888] 연산자 끼워넣기  (0) 2022.03.16
[백준 2580] 스도쿠  (0) 2022.03.16
[백준 9663] N-Queen  (0) 2022.03.11
[백준 15651] N과 M (4)  (0) 2022.03.11
[백준 15651] N과 M (3)  (0) 2022.03.11

+ Recent posts