# 동적계획법 (DP, Dynamic Programming) ## 동적계획법이란? ![image](https://hackmd.io/_uploads/Syxc3wCeR.png) - 중복되는 계산을 피하며, 최적해를 구하기 위해 작은 하위 문제들의 해결 결과를 저장하고 활용하는 알고리즘 설계 기법 - 동적 계획법은 큰 문제를 해결하기 위해 작은 하위 문제들의 최적해를 계산하는 과정을 반복하여 전체 문제의 최적해를 구한다. 이를 위해 다음과 같은 단계로 구성된다 1. 작은 규모의 하위 문제들로 분할: 원래 문제를 작은 크기의 하위 문제들로 분할한다. 2. 하위 문제들의 최적해 계산: 각 하위 문제들의 최적해를 계산한다. 이때, 중복 계산을 피하기 위해 **계산 결과를 저장**(메모이제이션)한다. 3. 최적해 조합: 계산된 하위 문제들의 최적해를 활용하여 전체 문제의 최적해를 조합한다. 4. 결과 반환: 전체 문제의 최적해를 반환하거나 필요에 따라 저장한다. - 동적 계획법은 최적 부분 구조와 중복 부분 문제라는 두 가지 핵심 개념에 기반을 두고 있다. 최적 부분 구조는 큰 문제의 최적해가 작은 하위 문제들의 최적해로 구성될 수 있는 성질을 의미하며, 중복 부분 문제는 작은 규모의 하위 문제들이 서로 중복되어 반복해서 해결되는 성질을 의미한다. - 장점 - 중복 계산을 피할 수 있음 - 최적 부분 구조 활용 가능 - 다양한 문제에 적용 가능 - 문제의 크기에 따라 Bottom-up 또는 Top-down 방식 선택 가능 - Bottom-up : 작은 하위 문제부터 순차적으로 계산하며 전체 문제의 최적해를 구하는 방법 - Top-down : 큰 문제를 해결하기 위해 재귀적으로 하위 문제들을 해결하는 방법 - 최적해 보장 - 단점 - 필요 메모리 공간 증가 - 하위 문제의 의존성 - 동적 계획법은 하위 문제들 간의 의존성이 있는 경우에 적용된다. 만약 하위 문제들이 서로 독립적이거나 의존성이 적다면 동적 계획법이 적합하지 않을 수 있다. - 최적 부분 구조의 확보 - 동적 계획법을 적용하기 위해서는 최적 부분 구조가 보장되어야 한다. 최적 부분 구조가 없는 문제에 동적 계획법을 적용하면 부분 문제들의 최적해를 조합하여 전체 문제의 최적해를 구할 수 없다. ### 메모이제이션 (Memoization) - 함수가 이전에 계산한 값을 기억하고, 동일한 입력이 들어오면 미리 계산한 값을 반환하여 중복 계산을 방지하는 기법 - 재귀 알고리즘의 효율성을 높이기 위한 방법 중 하나 - 동적계획법 알고리즘에서 중요한 역할을 하는 개념 ## 동적 계획법 예시 1. 피보나치 수열 2. 2×n 타일링 문제 - 2×1 타일로 2×n 직사각형을 채우는 방법의 수를 구하는 문제 - 백준 알고리즘 11726번 ==[링크](https://www.acmicpc.net/problem/11726)== 3. 쉬운 계단 수 문제 - 인접한 자리의 수가 1씩 차이나는 수를 계단 수라고 한다. N자리 수 중에서 계단 수인 수의 개수를 구하는 문제 - 백준 알고리즘 10844번 ==[링크](https://www.acmicpc.net/problem/10844)== 4. 평범한 배낭 문제 - 배낭에 담을 수 있는 무게의 최댓값이 정해져 있을 때, 배낭에 담을 수 있는 물건들의 가치의 최댓값을 구하는 문제 - 백준 알고리즘 12865번 ==[링크](https://www.acmicpc.net/problem/12865)== ### 피보나치 수열 ![image](https://hackmd.io/_uploads/SydtRwRlC.png) #### 재귀 함수를 사용한 피보나치 수열 ```java public class FibonacciRecursive { // 재귀 함수를 사용하여 피보나치 수열을 계산하는 함수 public static int fibonacci(int n) { if (n <= 1) { // 피보나치 수열의 첫 번째와 두 번째 항은 해당 인덱스 값과 동일 return n; } else { return fibonacci(n - 1) + fibonacci(n - 2); // 이전 두 항의 합으로 다음 항을 계산 } } public static void main(String[] args) { int n = 10; for (int i = 0; i <= n; i++) { System.out.print(fibonacci(i) + " "); // 0부터 n까지의 피보나치 수열 출력 } } } ``` #### 동적계획법을 사용한 피보니차 수열 (Top-down 방식) ```java public class FibonacciDynamicProgramming { private static int[] fibMemo; // 계산된 결과를 저장할 static 변수 public static int fibonacciTopDown(int n) { if (fibMemo[n] != -1) { // 이미 계산된 결과가 있다면 해당 값을 반환 return fibMemo[n]; } if (n <= 1) { return n; } else { fibMemo[n] = fibonacciTopDown(n - 1) + fibonacciTopDown(n - 2); // 계산 결과를 저장 return fibMemo[n]; } } public static void main(String[] args) { int n = 10; fibMemo = new int[n + 1]; Arrays.fill(fibMemo, -1); // 초기값 -1로 설정 for (int i = 0; i <= n; i++) { System.out.print(fibonacciTopDown(i) + " "); // 0부터 n까지의 피보나치 수열 출력 } } } ``` #### 동적계획법을 사용한 피보니차 수열 (Bottom-up 방식) ```java public class FibonacciDynamicProgramming { private static int[] fibMemo; // 계산된 결과를 저장할 static 변수 public static int fibonacciBottomUp(int n) { fibMemo[0] = 0; fibMemo[1] = 1; for (int i = 2; i <= n; i++) { fibMemo[i] = fibMemo[i - 1] + fibMemo[i - 2]; // 계산 결과를 저장 } return fibMemo[n]; } public static void main(String[] args) { int n = 10; fibMemo = new int[n + 1]; for (int i = 0; i <= n; i++) { System.out.print(fibonacciBottomUp(i) + " "); // 0부터 n까지의 피보나치 수열 출력 } } } ``` > Top-down 방식 : 이미 계산된 결과를 확인하여 저장한 후 반환한다 (재귀 사용) > Bottom-up 방식 : 작은 하위 문제부터 시작하여 계산 결과를 저장하고 최종 결과를 반환한다 (반복문 사용) ### 2×n 타일링 문제 - 백준 알고리즘 11726번 ==[링크](https://www.acmicpc.net/problem/11726)== - 위 문제를 보고 수도코드 작성 및 풀이를 해보자 #### 2×n 타일링 문제 수도코드 예 ```plaintext 1. 타일의 가로 길이 N을 입력 받는다. 2. N개의 타일로 구성할 수 있는 2×N 직사각형의 개수를 저장할 배열을 초기화한다. 3. 가로 길이가 1인 경우, 2×1 직사각형 하나로 구성할 수 있으므로 1로 초기화한다. 4. 가로 길이가 2인 경우, 2×2 직사각형 하나 또는 1×2 타일 두 개로 구성할 수 있으므로 2로 초기화한다. 5. 가로 길이가 3부터 N까지 다음을 반복한다: - 현재 타일을 놓을 수 있는 경우의 수는 이전 타일에서 2×1 타일을 놓는 경우와 1×2 타일 두 개를 놓는 경우를 합한 값이다. - 현재 타일을 놓을 수 있는 경우의 수를 배열에 저장한다. 6. N개의 타일로 구성할 수 있는 2×N 직사각형의 개수를 출력한다. ``` #### 2×n 타일링 문제 소스코드 예 ```java import java.util.Scanner; public class Main { public static final int MOD = 10007; // 나누기 연산에 사용할 상수 public static void main(String[] args) { Scanner scanner = new Scanner(System.in); int N = scanner.nextInt(); if(N == 1){ System.out.println(1); return; } int[] dp = new int[N + 1]; // N개의 타일로 구성할 수 있는 2×N 직사각형의 개수를 저장하는 배열 // 초기값 설정: 가로 길이가 1인 경우 1, 가로 길이가 2인 경우 2 dp[1] = 1; dp[2] = 2; // 가로 길이가 3부터 N까지 타일 개수 계산 for (int i = 3; i <= N; i++) { dp[i] = (dp[i - 1] + dp[i - 2]) % MOD; // 이전 타일에서 2×1 타일을 놓는 경우와 1×2 타일 두 개를 놓는 경우를 합한 값 } int answer = dp[N]; // N개의 타일로 구성할 수 있는 2×N 직사각형의 개수 System.out.println(answer); scanner.close(); } } ``` ### 쉬운 계단 수 문제 - 백준 알고리즘 10844번 ==[링크](https://www.acmicpc.net/problem/10844)== - 위 문제를 보고 수도코드 작성 및 풀이를 해보자 #### 쉬운 계단 수 문제 수도코드 예 ```plaintext 1. 자릿수 N을 입력 받는다. 2. N자리 수를 갖는 계단 수의 개수를 저장할 배열을 초기화한다. 3. 첫 번째 자릿수는 1부터 9까지의 계단 수로 초기화한다. 4. 두 번째 자릿수부터 N번째 자릿수까지 다음을 반복한다: - 현재 자릿수에서 가능한 이전 자릿수의 숫자를 확인한다. - 이전 자릿수의 숫자가 0인 경우, 다음 자릿수는 1로 시작하는 계단 수만 가능하다. - 이전 자릿수의 숫자가 9인 경우, 다음 자릿수는 8로 시작하는 계단 수만 가능하다. - 이전 자릿수의 숫자가 1~8인 경우, 다음 자릿수는 이전 자릿수의 숫자 - 1 또는 이전 자릿수의 숫자 + 1로 시작하는 계단 수가 가능하다. - 현재 자릿수의 가능한 계단 수의 개수를 구하여 배열에 저장한다. 5. 배열에 저장된 N자리 수를 갖는 계단 수의 개수를 모두 더하여 결과를 출력한다. ``` #### 쉬운 계단 수 문제 소스코드 예 ```java import java.util.Scanner; public class Main { public static final int MOD = 1000000000; // 나누기 연산에 사용할 상수 public static void main(String[] args) { Scanner scanner = new Scanner(System.in); int N = scanner.nextInt(); long[][] dp = new long[N + 1][10]; // N자리 수를 갖는 계단 수의 개수를 저장하는 배열 // 초기값 설정: 1자리 수는 1부터 9까지의 계단 수가 가능 for (int i = 1; i <= 9; i++) { dp[1][i] = 1; } // 두 번째 자릿수부터 N번째 자릿수까지 계단 수 개수 계산 for (int i = 2; i <= N; i++) { for (int j = 0; j <= 9; j++) { // 이전 자릿수의 숫자가 0인 경우, 다음 자릿수는 1로 시작하는 계단 수만 가능 if (j == 0) { dp[i][j] = dp[i - 1][j + 1] % MOD; } // 이전 자릿수의 숫자가 9인 경우, 다음 자릿수는 8로 시작하는 계단 수만 가능 else if (j == 9) { dp[i][j] = dp[i - 1][j - 1] % MOD; } // 이전 자릿수의 숫자가 1~8인 경우, 다음 자릿수는 이전 자릿수의 숫자 - 1 또는 이전 자릿수의 숫자 + 1로 시작하는 계단 수가 가능 else { dp[i][j] = (dp[i - 1][j - 1] + dp[i - 1][j + 1]) % MOD; } } } long answer = 0; // N자리 수를 갖는 계단 수의 개수를 모두 더함 for (int i = 0; i <= 9; i++) { answer = (answer + dp[N][i]) % MOD; } System.out.println(answer); scanner.close(); } } ``` ### 평범한 배낭 문제 - 백준 알고리즘 12865번 ==[링크](https://www.acmicpc.net/problem/12865)== - 위 문제를 보고 수도코드 작성 및 풀이를 해보자 #### 평범한 배낭 수도코드 예 ```plaintext 1. 물품의 수 N과 최대 무게 K를 입력 받는다. 2. 물품의 무게와 가치를 입력 받는다. 3. 물품을 담을 수 있는 최대 가치를 저장할 배열을 초기화한다. 4. 첫 번째 물품부터 N번째 물품까지 다음을 반복한다: - 현재 물품을 선택한 경우와 선택하지 않은 경우 중에서 가치가 더 큰 경우를 선택한다. - 현재 물품을 선택한 경우, 남은 무게가 물품의 무게보다 크거나 같은 경우에만 선택할 수 있다. - 현재 물품을 선택한 경우, 현재 물품의 가치와 이전에 선택한 물품까지의 가치를 더한 값을 저장한다. - 현재 물품을 선택하지 않은 경우, 이전까지의 최대 가치를 그대로 가져온다. 5. 물품을 선택한 경우와 선택하지 않은 경우 중에서 가치가 더 큰 값을 최대 가치로 선택한다. 6. 최대 가치를 출력한다. ``` #### 평범한 배낭 소스코드 예 ```java import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); int N = scanner.nextInt(); int K = scanner.nextInt(); int[] weights = new int[N + 1]; int[] values = new int[N + 1]; for (int i = 1; i <= N; i++) { weights[i] = scanner.nextInt(); values[i] = scanner.nextInt(); } int[][] dp = new int[N + 1][K + 1]; // 물품을 담을 수 있는 최대 가치를 저장하는 배열 // 물품을 선택할 수 있는 경우의 수마다 최대 가치 계산 for (int i = 1; i <= N; i++) { for (int j = 1; j <= K; j++) { // 현재 물품을 선택한 경우와 선택하지 않은 경우 중에서 가치가 더 큰 값을 선택 if (weights[i] <= j) { dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i]] + values[i]); } else { dp[i][j] = dp[i - 1][j]; } } } int answer = dp[N][K]; // 최대 가치 System.out.println(answer); scanner.close(); } } ```