Coding Test/Algorithm & Data Structure

[C++] 가장 긴 증가하는 부분 수열 (LIS, Longest Increasing Subsequence)

짱정연 2023. 12. 10. 00:57
반응형

1. 부분 수열과 증가하는 부분 수열이란?

부분 수열

부분 수열이란 주어진 수열의 일부 항을 원래 순서대로 나열하여 얻을 수 있는 수열을 의미한다.

만약 { 3, 2, 5, 2, 3, 1, 4 } 라는 수열이 있다면 {3}, {2}, {2, 5}, {3, 5, 3, 1} 등이 해당 수열의 부분 수열이 되며, 수열 자체도 자기 자신의 부분 수열에 포함된다.

증가하는 부분 수열

증가하는 부분 수열은 부분 수열들 중에서도 그 수가 오름차순으로 증가하는 부분 수열을 의미한다.

위에 있던 수열의 증가하는 부분 수열은 {2, 5}, {2, 3, 4} 등이 있고, 원소가 하나일 때도 증가하는 부분 수열에 포함된다.

 

이번 포스트에서 소개할 가장 긴 증가하는 부분 수열은 그 중에서도 가장 길이가 긴 것을 의미한다. 가장 긴 증가하는 부분 수열은 말이 길기 때문에 LIS라고 자주 불린다.

 

LIS는 주로 DP를 활용하여 구현하

2. LIS 구현 1 - O(N^2) 알고리즘

LIS는 주로 DP를 활용하여 구현한다.

dp[i]가 i번째 원소를 마지막으로 하는 LIS의 길이를 의미할 때,
dp[i]는 0 ~ i-1번째 원소 중 값은 arr[i]보다 작지만 가장 큰 DP 값을 가지는 원소의 DP 값에 1을 더한 값이다.

 

표를 그려보면서 예시를 들어보자. 편의를 위해 arr의 원소는 인덱스가 1부터 시작한다고 가정한다.

 

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 1        

i=1

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 1 2      

i=2

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 1 2 3    

i=3

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 1 2 3 2  

i=4

arr[3] > arr[4]이기 때문에 dp[4] = dp[2] + 1이다.

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 1 2 3 2  

i=5

해당 방법은 dp[i]를 구하기 위해 순차 탐색을 진행하기 때문에 전체 시간 복잡도는 O(N^2)이 된다.

 

이를 코드로 구현하면 아래와 같다.

 

11053번: 가장 긴 증가하는 부분 수열

수열 A가 주어졌을 때, 가장 긴 증가하는 부분 수열을 구하는 프로그램을 작성하시오. 예를 들어, 수열 A = {10, 20, 10, 30, 20, 50} 인 경우에 가장 긴 증가하는 부분 수열은 A = {10, 20, 10, 30, 20, 50} 이

www.acmicpc.net

 

#include <bits/stdc++.h>

using namespace std;

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

    int n;
    cin >> n;

    vector<int> arr = vector<int>(n+1);
    vector<int> dp = vector<int>(n+1);
    arr[0] = 0;
    dp[0] = 0;
    dp[1] = 1;

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

    for (int i = 2; i < n+1; ++i) {
        for (int j = 0; j < i; ++j) {
            if (arr[j] < arr[i]) dp[i] = max(dp[i], dp[j]+1);
        }
    }

    int ret = 0;
    for (auto elem : dp) {
        if (elem > ret) ret = elem;
    }

    cout << ret << "\n";

    return 0;
}

 

2. LIS 구현 2 - O(NlogN) 알고리즘

앞선 방법에서는 dp[i]를 갱신할 때 순차 탐색으로 구현하였는데 이분 탐색을 사용하여 구현하면 시간 복잡도를 줄일 수 있다.

 

이 때는 DP 배열을 다르게 정의해야 한다.

dp[i]가 길이가 i인 증가하는 부분 수열 중 끝점의 최솟값을 의미하고 현재 탐색 중인 원소를 here이라고 할 때,

dp 배열이 비어 있거나 dp의 마지막 원소보다 here이 더 클 경우, dp 배열의 뒤에 here을 추가
그렇지 않을 경우, dp 배열에서 here의 lower_bound를 찾아 그 자리를 here로 바꾼다.
  • lower_bound: 찾으려는 값 이상의 수가 처음 등장하는 위치(인덱스)

 

이번에도 표를 그리면서 이해를 해보자.

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 10        

i=1

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 10 20      

i=2

 

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 10 20 30    

i=3

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 10 15 30    

i=4

 

i=4일 때는 dp 배열이 비어 있지도 않고, here(=15)이 dp의 마지막 원소인 30보다 작다. 그리고 here의 lower_bound는 2이기 때문에 dp[2]가 15로 갱신된다.

 

i 0 1 2 3 4 5
arr[i] 0 10 20 30 15 25
dp[i] 0 10 15 25    

i=5

 

비슷하게 i=5일 때 25의 lower_bound는 3이므로 dp[3]가 25가 된다.

 

이 때 가장 긴 증가하는 부분 수열의 길이는 DP 배열의 길이-1이 된다. (0번째 원소는 무시하기 때문)

DP 배열은 항상 오름차순으로 유지되기 때문에 이분 탐색을 사용할 수 있고, 이분 탐색을 이용하므로 시간 복잡도도 줄일 수 있는 것이다.

 

앞에서 풀었던 백준 11053번을 해당 방법으로 다시 풀면 아래와 같다.

#include <bits/stdc++.h>

using namespace std;

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

    int n;
    cin >> n;

    vector<int> arr = vector<int>(n+1);
    vector<int> dp;
    arr[0] = 0;

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

    for (int i = 1; i < n+1; ++i) {
        // 비어있거나 dp의 마지막 원소보다 here이 크다면 dp에 here을 추가
        if (dp.empty() || dp.back() < arr[i]) {
            dp.push_back(arr[i]);
        } else { // 그렇지 않다면 lower_bound를 찾아 dp 배열 갱신
            int lb = lower_bound(dp.begin(), dp.end(), arr[i]) - dp.begin();
            dp[lb] = arr[i];
        }
    }

    cout << dp.size() << "\n";
    return 0;
}

 

LIS를 구성하는 원소들을 구하는 방법

알고리즘을 수행한 뒤 마지막에 dp에 들어있는 원소들은 LIS의 구성 요소들이 아니다. 그러므로 LIS를 구성하는 원소들을 알고 싶다면 추가적인 처리가 필요하다. 그 방법은 다음과 같다.

 

또 다른 배열 P를 만들고, P[i]에 수열의 i번째 원소가 dp 내에서 위치하는 인덱스를 저장한다.

 

#include <bits/stdc++.h>

using namespace std; 
int arr[1001], L[1001], P[1001], len, N;

void backtrace(int idx, int num) { 
    if(idx == 0) return; 
    if(P[idx] == num) { 
        backtrace(idx - 1, num - 1); 
        cout << arr[idx] << " "; 
    } else { 
        backtrace(idx - 1, num); 
    }
}

int main() { 
    ios_base::sync_with_stdio(false);
    cin >> N; 
    for(int i = 1; i <= N; ++i) { 
        cin >> arr[i]; 
        auto pos = lower_bound(L + 1, L + len + 1, arr[i]); 
        *pos = arr[i]; 
        P[i] = distance(L, pos); 
        if(pos == L + len + 1) len++;
    } 
    
    cout << len << "\n"; 
    backtrace(N, len); 
    return 0;
}

 

distance()는 벡터에서 해당 데이터가 어디에 위치하는지 가져오는 메서드이다.

 

Reference

 

가장 긴 증가하는 부분 수열 (Longest Increasing Subsequence)

어떠한 수열에서 가장 긴 증가하는 부분 수열(Longest Increasing Subsequence)를 찾는 문제는 꽤나 유명한 문제입니다. 다이나믹 프로그래밍을 공부할 때 예시로 자주 나오는 문제이고, 프로그래밍 대회

seungkwan.tistory.com

 

 

[알고리즘] 가장 긴 증가하는 부분 수열(Longest Increasing Subsequence)

가장 긴 증가하는 부분 수열 가장 긴 증가하는 부분 수열(LIS, Longest Increasing Subsequence) 혹은 최장 증가 부분 수열은 대표적인 동적 계획법(Dynamic programming) 문제다. 아래와 같이 특정 길이의 수열 A

buyandpray.tistory.com

 

반응형