[백준 14003] 가장 긴 증가하는 부분 수열 5

이준혁·2022년 6월 27일
0

coding-test

목록 보기
9/11
post-thumbnail
post-custom-banner

이 글은 2021.07.15에 작성했던 코딩 테스트 문제 풀이를 재 업로드한 게시물입니다.


문제 설명

이 문제는 어떤 수열이 주어졌을 때 그 수열 내의 가장 긴 증가하는 부분 수열과 그 길이를 구하는 문제입니다. 수열의 길이는 최대 10610^6이므로 O(N2)O(N^2) 알고리즘으로는 풀기 힘듭니다.


사전 지식

O(N2)O(N^2) 으로 가장 긴 증가하는 부분 수열의 길이 구하기

가장 긴 증가하는 수열을 영어로 Longest Increasing Subsequence, 줄여서 LIS라고 부르겠습니다. 또한 입력으로 주어진 수열을 AiA_i (i는 1 이상 N 이하, N은 수열 A의 길이)라고 표시하겠습니다.

어떤 수열의 부분 수열 중 LIS의 길이를 구하는 방법부터 차근차근 생각해 봅시다. 하지만 처음부터 전체 수열을 보면서 구하기는 힘듭니다. 그렇다면 생각을 조금 바꿔서 모든 i에 대해 AiA_i를 마지막 값으로 가지는 LIS의 길이를 구해보면 어떨까요? 이 값을 LiL_i라고 합시다. 용어를 정리하면 다음과 같습니다.

AiA_i : 입력으로 주어진 수열

LiL_i : AiA_i를 마지막 값으로 가지는 LIS의 길이

즉. LiL_i를 구하기 위해서는 수열 A1A_1, A2A_2, ... , ANA_N에서 AiA_i를 반드시 포함하는 증가하는 부분 수열 중 가장 긴 것의 길이를 찾으면 됩니다. 모든 i에 대해 LiL_i를 구할 수 있으면 LiL_i의 최댓값이 LIS의 길이가 됩니다.

그렇다면 LiL_i를 어떻게 구할 수 있을까요? 이를 구하기 위해서는 L1L_1부터 Li1L_{i-1}까지의 모든 값들을 이용하면 됩니다. AjA_jAiA_i보다 작은 모든 Lj(1<=j<=i1)L_{j} (1 <= j <= i - 1)들 중 최댓값 LkL_k를 찾았다고 생각해 봅시다. 그렇다면 AiA_i를 마지막으로 하는 LIS는, AkA_k를 마지막으로 하는 LIS에 AiA_i를 붙인 수열이 됩니다. 즉, Li=Lk+1L_i = L_k + 1이 되는 셈이죠. 이를 단계별로 정리하면 다음과 같습니다.

  1. i보다 작은 모든 j에 대해서, AjA_jAiA_i 보다 작은 j들을 찾습니다.

  2. 해당 j들에 대해 LjL_j의 값들 중 가장 큰 값을 LkL_k를 찾습니다.

  3. Li=Lk+1L_i = L_k + 1이 됩니다.

그림으로 표시하면 다음과 같습니다. 단, 예시에서는 입력으로 주어지는 모든 수가 양수인 경우만 다루었습니다.

i = 5일 때 LiL_i를 구하기 위해 AiA_i보다 작은 AjA_j값들을 모두 살펴보고, 그중 LjL_j값이 가장 큰 것은 L3L_3입니다. 이를 이용해 L5=L3+1=3L_5 = L_3 + 1 = 3을 얻습니다. L6L_6, L7L_7, L8L_8을 구해보면 각각 3, 4, 4가 됩니다.

1부터 N까지 모든 i에 대해 LiL_i를 구하면 됩니다. 각 과정마다 앞에 있는 모든 LjL_j의 값을 참고해야 하니 O(N)O(N)의 시간 복잡도를 가집니다. 즉, 총 시간 복잡도는 O(N2)O(N^2)입니다. L을 모두 구한 후 이 수열의 최댓값을 구하면 최대 LIS의 길이를 알 수 있습니다.

0번째 값

일반적인 컴퓨터 프로그래밍에서 모든 배열의 인덱스의 시작은 0입니다. 하지만 특별한 경우에는 1부터 시작하고, 지금 이 문제 역시 그랬죠. 왜 1부터 시작했을까요?

수열 L의 첫 번째 값 L1L_1은 앞에 어떠한 값도 없기 때문에 2.1의 과정을 수행할 수 없습니다. 사실 이 값은 무조건 1이 될 수밖에 없습니다. 하지만 구현의 편의성을 위해 수열 A의 모든 값보다 작은 A0A_0를 맨 앞에 추가합니다. 이렇게 되면 별도의 초기값 설정 없이도 일관적인 구현이 가능해집니다.

다만 A0A_0는 저희가 임의로 설정한 값이기 때문에 길이에 포함이 되면 안 됩니다. 즉, A0A_0는 길이에 계산이 되지 않는다고 생각하면 됩니다. 따라서 L1=L0+1L_1 = L_0 + 1이 될 텐데, A_0는 길이에 포함이 안되므로 L0L_0를 0으로 두면 해결됩니다.


접근 방법

O(NlogN)O(NlogN)으로 가장 긴 증가하는 부분 수열의 길이 구하기

이전 장에서 LIS의 길이를 구할 때, LiL_i의 값을 구하기 위해 i보다 작은 인덱스 j를 가지는 모든 AjA_j값을 다 살펴봤습니다. 이 과정이 O(N)의 시간 복잡도를 요구하기 때문에 총 O(N2)O(N^2)의 시간 복잡도를 가졌습니다. 반면 이렇게 생각해볼 수 있겠죠.

현재 값 AiA_i보다 작은 AjA_j값들 중 LjL_j의 값이 가장 큰 것을 이진 탐색으로 찾을 수 있지 않을까?

이진 탐색으로 이 값을 찾을 수 있다면 각 과정에서의 시간 복잡도는 O(logN)O(logN)이 되고, 총 시간 복잡도는 O(NlogN)O(NlogN)이 됩니다. 그렇다면 이진 탐색으로 j값을 어떻게 찾을 수 있을까요?

새로운 수열 M을 정의합시다. M은 다음과 같습니다.

MkM_k : Lj=kL_j = k를 만족하는 AjA_j의 최솟값.

배열 M이 존재하면 i를 1부터 N까지 증가시키며 LiL_i를 구할 수 있습니다. 이진 탐색을 이용해 수열 M의 원소들 중 AiA_i보다 작은 수 중에서 가장 큰 값을 구하면 됩니다. 해당 값의 인덱스를 k라고 하면 Li=k+1L_i = k + 1이 됩니다.

반대로 M 역시 i = 1부터 N까지 증가시키면서 채워집니다. 위에서 구한 LiL_i의 값이 기존에 있는 값이 아니면, MLiM_{L_i}의 값을 AiA_i의 값으로 저장하면 됩니다. 반면 LiL_i의 값이 기존에 존재하는 값이 아니라면, AiA_iMLiM_{L_i}보다 작은 지 확인해서 작다면 AiA_i값으로 MLiM_{L_i}의 값을 바꾸면 됩니다.

여기서 주목할만한 점이 있습니다. AiA_iMLiM_{L_i}보다 작은지 굳이 확인하는 과정이 필요할까요? MLi1M_{L_i - 1}AiA_i보다 작은 수 중에서 가장 큰 값입니다. 그렇다면 반대로 MLiM_{L_i}는 무조건 AiA_i보다 크겠죠. 즉, 어떤 경우든 확인할 필요 없이 AiA_iMLiM_{L_i}로 대체해주면 됩니다.

LiL_i와 M을 채워가는 과정을 그림으로 설명하면 다음과 같습니다. 이 예시에서도 입력으로 받은 모든 수가 양수임을 가정했습니다.

수열 M의 0번째 값

Lk=0L_k = 0을 만족시키는 AkA_k는 0 밖에 없으므로, M0M_0A0A_0가 됩니다. 이는 반드시 원소가 정렬되어야 하는 이진 탐색에 알맞습니다.

수열 구하기

O(NlogN)O(NlogN)의 시간 내에 수열의 길이를 구하는 법은 알아보았습니다. 여기서 한 발자국 더 나아가 그 수열 자체를 구하려면 어떻게 해야 할까요?

답은 간단합니다. AiA_i를 마지막 값으로 가지는 LIS중 AiA_i 이전에 오는 값의 인덱스를 저장하는 배열을 만들면 됩니다. 이 배열을 P라고 합시다. PiP_iAiA_i를 마지막 값으로 가지는 LIS중 AiA_i 이전에 오는 값의 인덱스입니다.

PiP_i를 구하기 위해서는 i를 1부터 N까지 증가시켜가면서 LiL_i를 구할 때 나오는 값을 이용합니다. MLi1M_{L_i - 1}의 값이 AkA_k라고 한다면, 이 값이 AiA_i 이전에 오는 값이 되므로, Pi=kP_i = k가 됩니다.

다만 구현에서는 LjL_j별로 AiA_i의 값을 저장하는 수열 M 대신 AiA_i의 인덱스, 즉 i값을 저장하는 또 다른 배열 T를 만들어야 합니다.

이 과정을 거친 다음에 모든 L값 중 가장 큰 LML_M의 값을 구하면, ATMA_{T_M}으로 끝나는 수열이 LIS가 됩니다. PTMP_{T_M}ATMA_{T_M}의 이전 값의 인덱스이고, 이 값을 이용해 이전 값을 구할 수 있습니다. P 수열을 계속 이용하면 순차적으로 이전 값들을 구할 수 있고, PkP_k = 0이 될 때까지 반복하면 LIS를 구할 수 있습니다.

다만 주의할 점은 큰 값에서 작은 값으로 감소하는 식으로 수열을 구했기 때문에 출력할 때는 반대로 해 주면 됩니다.


코드 설명

배열 설명

input은 A 수열을, endval_per_length는 본문의 M 배열을, endidx_per_length는 본문의 T 배열을, prev_idx는 본문의 P 배열을 나타냅니다.

endval_per_length의 초항 M0M_0A0A_0이고, 이 값은 A의 모든 값보다 작아야 하므로 입력으로 주어지는 모든 값보다 작게 설정했습니다.

#define MAXLEN 1000000
#define LOWER_BOUND -1000000001

int input[MAXLEN+1] = {0};

/* The minumum end value of a subsequence whose length is its idx */
int endval_per_length[MAXLEN+1] = {LOWER_BOUND};

/* Index of the endval_per_length */
int endidx_per_length[MAXLEN+1] = {0};

/* Index of former value corresponding to current subsequence */
int prev_idx[MAXLEN+1] = {0};

이진 탐색 함수

배열과 왼쪽 끝, 오른쪽 끝 인덱스와, 찾고자 하는 값을 입력으로 줍니다. 배열의 값들 중 찾고자 하는 값보다 작은 값 중에서 가장 큰 값의 인덱스를 찾아 반환합니다. 배열 M을 통해 LiL_i를 구하기 위해 사용됩니다.

/* Find the index of the certain value */
int binary_search_idx(int* arr, int left, int right, int val){
    int mid;
    while(left != right){
        mid = (left + right + 1) / 2;
        if(val <= arr[mid]){
            right = mid - 1;
        }
        else{
            left = mid;
        }

    }
    return left;
}

입력 배열 탐색

i는 1부터 N까지 증가시키며 배열 M을 이진 탐색을 통해 LiL_i를 구합니다. LiL_i가 최대 길이보다 이를 기록해 줍니다. MLiM_{L_i}, TLiT_{L_i}를 각각 AiA_i, i로 업데이트합니다. 또한 PiP_iMLi1M_{L_i - 1}로 갱신하면 됩니다.

for(int idx = 1; idx <=input_length; idx++){
    /* cur length means the maximum legth of subseq that ends with current value */
    cur_length = binary_search_idx(endval_per_length, 0, max_length, input[idx]) + 1;

    /* max length is maximum lengths among cur lengths */
    if(cur_length > max_length){
        max_length = cur_length;
    }

    /* update the end value as current value */
    endval_per_length[cur_length] = input[idx];
    endidx_per_length[cur_length] = idx;

    prev_idx[idx] = endidx_per_length[cur_length - 1];
}

부분 수열 구하기

cur_idx에는 최고 길이를 가질 때 마지막 값의 인덱스를 저장해 둡니다. 이후 cur_idx가 0이 아닐 때까지 계속 이전 인덱스를 추적해 나갑니다. 또한 그 인덱스에 해당하는 값을 LIS 벡터에 순차적으로 저장합니다.

std::vector<int> result;
int cur_idx = endidx_per_length[max_length];

/* Stack the value of the LIS at the vector */
while(cur_idx != 0){
    result.push_back(input[cur_idx]);
    cur_idx = prev_idx[cur_idx];
}

다만 위에서 언급한 대로 LIS 벡터에는 증가하는 부분 수열의 마지막부터 역순으로 저장되어 있으므로 출력 역시 반대로 해 주면 됩니다.

/* Print the results in the right order */
std::vector<int>::reverse_iterator rit;
for(rit = result.rbegin(); rit != result.rend(); rit++){
    printf("%d ", *rit);
}

여담

위키들을 참고해서 푼 최장 증가 부분 수열입니다.

코드 원본은 여기를 참고해 주시면 됩니다.

참고

  1. 백준 14003
  2. 백준 12015
  3. 최장 증가 부분 수열
  4. Longest increasing subsequence
profile
만들고 개선하는 것을 좋아하는 개발자
post-custom-banner

0개의 댓글