대표적인 DP 문제로 두가지 버전으로 문제를 해결할 수 있다.
기억해두어야하는 유형일것같아서 기록한다.
수열 A가 주어졌을 때, 가장 긴 증가하는 부분 수열을 구하는 프로그램을 작성하시오.
예를 들어, 수열 A = {10, 20, 10, 30, 20, 50} 인 경우에 가장 긴 증가하는 부분 수열은 A = {10, 20, 10, 30, 20, 50} 이고, 길이는 4이다.
처음에는 단순하게 DP테이블을 구성했다.
DP테이블은 1차원 배열을 이루고있고,해당 인덱스 번호까지 갔을때의 최대 수열 길이를 저장하는 것을 목표로 구성했다.
이전의 숫자보다 큰 숫자가 나올때마다 dp 테이블의 이전항 + 1을 저장했다.
하지만,이 풀이법은 틀렸다.
왜냐하면, 시작지점이 무조건 0번항이라고 생각하기 때문이다. 그리고 무조건 큰 수를 찾아가기 때문에 중간에 더 긴 부분 수열이 나올 수 있는 가능성을 배제하기 때문이다.
예시는 다음과 같다.
10 20 30 15 20 25
10 - 20 - 30 까지는 순조롭게 수열이 이루어지고있다.
15가 들어가게 되면 어떻게 될까?
10 - 20 - 30 - 15 로, 15는 이후에 들어갈 수 없게 되어버린다.
즉, 이 경우에서 나올 수 있는 수열은
10 - 20 - 30 으로 3개의 원소를 가진 수열이 된다.
이를 다르게 생각해보자,
10 - 15 로, 뒤에있는 20과 30을 탈락시킨다면?
10 - 15 - 20 - 50 4개의 원소를 가진 수열이 된다.
즉,숫자는 커지되, 숫자가 커지는 폭은 작아져야 긴 수열이 될 수 있다는 것이다.
이 로직을 이진탐색으로 구현한 방법은 O(nlogn)의 시간 복잡도를 가진다.
하지만 이 방법에 앞서 쉽게 해결할 수 있는 방법을 알아보자, 물론 이 방법은 O(n^2)의 시간복잡도를 가지기 때문에,N의 크기가 커지면 문제를 해결하기가 어렵다!
두가지 방법을 모두 살펴보자.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;
/**
* @author onyoo
* @performance
* @category
* @note
* DP : 1차원 배열 , 해당 인덱스 길이의 숫자까지 왔을 때 가장 긴 수열의 길이를 저장
* @see https://www.acmicpc.net/problem/11053
* @since 2023-10-25
**/
public class Main {
static int N;
static int[] arr,dp;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st;
N = Integer.parseInt(br.readLine());
arr = new int[N];
dp = new int[N]; // dp table
dp[0] = 1; // 초항
st = new StringTokenizer(br.readLine()," ");
for(int i=0;i<N;i++){
arr[i] = Integer.parseInt(st.nextToken());
}
int answer = 0;
for(int i=0;i<N;i++){
int max = 0;
for(int j=0;j<i;j++){
if(arr[i] > arr[j]) max = Math.max(max,dp[j]);
}
// 현재 값 보다 큰 값을 가지면서, dp 테이블의 값이 가장 큰 (수열의 길이가 긴) 경우를 고른다
// 값을 구하지 못할 경우 0이기 때문에 상관없다
dp[i] = max + 1;
answer = Math.max(dp[i],answer); // dp 테이블에서 가장 큰 값이 답이다 !
}
System.out.println(answer);
}
}
이 코드는 현재 원소보다 큰 값을 가지면서 dp 테이블의 값이 가장 큰 경우를 고르는 코드이다.
10 20 10 30 20 50 이 있다고 가정해보자.
DP[0] = 1 이다.
max 값은 0으로 DP[i]를 계산할때마다 초기화를 한다.
DP[i]를 구하기 위하여 우리는 i번째 원소보다 작은 j번째 원소를 찾을 것이다. 단, i > j 이다. 왜냐하면,수열이 이어지기 위해서는 i번째의 원소값이 j번째 원소값보다 커야 이어질 수 있기 때문이다.
조건을 만족한 값들 중 DP[j] 값과 max 값을 비교하며 가장 큰 DP[j] 값을 찾는다. 그래야 긴 수열이 될 수 있기 때문이다.
만약, max값이 0이 아닌상태로 계속 있다면 가장 긴 수열의 값이 + 1 로 업데이트가 될 것이고. 그렇지 않다면 현재 원소를 포함하여 가장 긴 수열의 길이가 1이 될 것이다 (max의 초깃값이 0이기 때문에 가능하다)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.StringTokenizer;
/**
* @author onyoo
* @performance
* @category
* @note
* DP : 1차원 배열 , 해당 인덱스 길이의 숫자까지 왔을 때 가장 긴 수열의 길이를 저장
* N의 크기가 1,000,000 으로 크다 -> 최적화 필요함 O(logN)의 시간 복잡도를 가지는 방법을 사용해야한다
* 배열의 끝점이 최소가 되도록 유지해야한다
* 배열의 끝점이 최소가 된다 -> 이후로 더 많은 수를 받을 수 있다.
* 끝점이 커져버리게 되면, 이것보다 작은 숫자들은 못받기 때문에 !
* https://buyandpray.tistory.com/73 (참고)
* @see https://www.acmicpc.net/problem/11053
* @since 2023-10-25
**/
public class Main {
static int N;
static int[] seq,lis;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st;
N = Integer.parseInt(br.readLine());
seq = new int[N];
lis = new int[N];
st = new StringTokenizer(br.readLine()," ");
for(int i=0;i<N;i++){
seq[i] = Integer.parseInt(st.nextToken());
}
lis[0] = seq[0];
int length = 1; // lis의 길이
for(int i=1;i<N;i++){
int key = seq[i];
if(lis[length-1] < key){
length++;
lis[length-1] = key;
}
else{
//이전것보다 더 작은게 나온다면
int low = 0;
int high = length;
while(low < high){
int mid = (low + high) >>> 1;
if(lis[mid] < key){
low = mid + 1;
}else{
high = mid;
}
}
lis[low] = key;
}
}
System.out.println(length);
}
}
이건 위에서 설명한 그 내용을 이해하면 코드가 쉽게 이해 될 것 이라고 생각한다.
요건, 1번 방법과는 약간의 차이가 존재한다. 바로, 수열값 자체를 저장하는 방식으로 진행된다.
위의 예시를 다시 보자
10 20 30 15 20 25 가 있다고 해보자.
10 20 30 까지는 순조롭게 진행된다. 15 부터가 문제가 발생할 수 있다.
우리가 중요하게 보아야 할 것은 끝점이다.
끝점을 30으로 둘 것이냐 15로 둘 것이냐이다.
현재 끝점을 30으로 둔다면 이후의 숫자들은 무조건 30 이상이 되어야 한다.
만약에 끝점을 15로 둔다면? 이후의 숫자들은 15이 된다.
이 말은 즉,30으로 둘때보다 15로 둘때 긴 수열이 될 확률이 높다는 것이다.
그렇기 때문에 우리는 이 update 과정을 이진탐색으로 구현하고자한다.
끝점보다 작은 값이 등장하면 끝점이 들어갈만한 중간자리를 찾아낸 다음 그 자리에 작은 값을 넣어 배열을 갱신한다.