
가장 긴 증가하는 부분 수열(Longest Increasing Subsequence; LIS)은 DP로 풀 수 있는 유명한 문제 유형 중 하나입니다. 문제의 핵심은 주어진 시퀀스에서 오름차순으로 정렬된 가능한 가장 긴 부분 수열을 찾는 것입니다. 참고로 답이 되는 부분 수열은 꼭 하나가 아니라 여러 개가 될 수도 있습니다.
이 문제를 푸는 방법은 크게 두 가지가 알려져 있습니다. DP로만 풀기와 DP + 이분탐색으로 풀기입니다. 각각 O(n²)와 O(n log(n))의 시간복잡도를 가집니다. n이 작으면 전자의 방법으로도 충분할 수 있지만 n이 더 커지면 힘들겠죠.
풀어볼 문제는 가장 긴 증가하는 부분 수열5입니다. 문제는 아주 짧고 명확합니다. 예를 들어 수열 A가 A = {10, 20, 10, 30, 20, 50}로 주어졌을 경우에 이 중에서 가장 긴 증가하는 부분 수열인 A = {10, 20, 10, 30, 20, 50} 을 찾고 그 길이인 4를 찾는 것입니다.
문제의 조건으로 수열 A의 크기 N의 범위가 중요한데요. 위 문제에서 N의 범위는 maximum 1,000,000으로 O(n²)의 시간복잡도를 가지는 방식으로 푼다면 조 단위의 연산이 필요합니다. 그래서 이를 개선한 방식을 통해 O(n log(n))로 최적화 해서 n * log(n) = 1,000,000 * log(1,000,000) = 약 20,000,000 단위의 연산으로 줄이는 거죠. 최적화하기 위해서는 한 가지 중요한 컨셉을 알아야 합니다.
자세한 건 뒤에서 다시 설명하도록 하고, 먼저 기초적으로 생각할 수 있는 방식에 대해서 생각해볼게요. 바로 모든 경우의 수를 찾는 완전탐색입니다. 모든 경우의 수를 따지면 가장 긴 부분 수열을 찾을 수 있겠죠? 분명히 비효율적이겠지만 한번 코드만 간단히 보고 넘어가 봅시다. 모든 경우의 수를 찾기 위해 DFS로 풀면 다음과 같습니다.
import java.io.*;
import java.util.*;
/**
* LIS - DFS
*/
public class Main {
static int N;
static int[] arr;
static LinkedList<Integer> answer = new LinkedList<>(); // 마지막 원소를 O(1)로 확인하기 위해 LinkedList로 할당
public static void main(String[] args) throws IOException {
setting(); // 입력 세팅
LinkedList<Integer> sequence = new LinkedList<>(); // 부분 수열을 담기 위한 연결 리스트 생성
dfs(0, sequence); // 모든 경우의 수를 찾기 위해 DFS 수행
// 결과 출력
StringBuilder sb = new StringBuilder();
sb.append(answer.size()).append("\n");
for (Integer elem : answer) {
sb.append(elem).append(" ");
}
System.out.println(sb);
}
private static void dfs(int index, LinkedList<Integer> seq) {
if (index >= N) { // 모든 인덱스를 순회했다면
if (answer.size() < seq.size()) { // 현재 정답 후보보다 더 큰 부분수열을 찾았다면
answer = new LinkedList<>(seq); // 깊은 복사로 새로운 정답 후보로 교체
}
return;
}
// 만약 수열이 비어있거나 가장 마지막 원소보다 큰 값이 arr[index]라면
if (seq.isEmpty() || arr[index] > seq.getLast()) {
seq.add(arr[index]); // 수열에 추가하고
dfs(index + 1, seq); // 다음 index에 dfs 적용
seq.removeLast(); // 수열에서 다시 빼기
}
dfs(index + 1, seq); // 현재 원소 건너뛰기
}
private static void setting() throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
N = Integer.parseInt(br.readLine());
arr = new int[N];
StringTokenizer st = new StringTokenizer(br.readLine());
for (int i = 0; i < N; i++) {
arr[i] = Integer.parseInt(st.nextToken());
}
}
}
LinkedList answer 와 sequence 를 정의하고 DFS를 돌면서 sequence 에 조건에 맞는 원소들을 저장한 뒤, answer 보다 리스트의 길이가 길다면 깊은 복사하는 방법입니다. 코드에서는 sequence 의 마지막 값보다 원소가 작은 경우를 가지치기하므로 일부 연산이 절약될 수 있지만 기본적으로 O(n!)의 풀이입니다. 이제 최적화해봅시다.
모든 경우의 수를 찾아서 푸는 위의 DFS 방법에서 중요한 정보를 지나쳤습니다. 앞서 연산한 결과를 고려하지 않고 매번 새롭게 풀기 때문이죠. 중복된 연산을 하지 않도록 최적화할 수 있을 것 같습니다. 배열을 앞에서부터 순서대로 순회하면서 이전 원소들 중에 현재 원소보다 작으면서 가장 긴 길이를 업데이트하면 어떨까요. A = {10, 20, 10, 30, 20, 50}를 예로 들면 다음과 같습니다.
•
index: 0, 서브 배열 A’ = {10} ⇒ 가장 긴 길이 : 1
•
index: 1, 서브 배열 A’ = {10, 20} ⇒ 가장 긴 길이 : 2
•
index: 2, 서브 배열 A’ = {10, 20, 10} ⇒ 가장 긴 길이 : 2
•
index: 3, 서브 배열 A’ = {10, 20, 10, 30} ⇒ 가장 긴 길이 : 3
•
index: 4, 서브 배열 A’ = {10, 20, 10, 30, 20} ⇒ 가장 긴 길이 : 3
•
index: 5, 서브 배열 A’ = {10, 20, 10, 30, 20, 50} ⇒ 가장 긴 길이 : 4
즉, 여기서 dp배열은 arr[i]를 마지막 원소로 하는 LIS의 길이가 됩니다. 수식으로 보면 다음과 같습니다.
dp[i] = max(dp[j] + 1) (0 ≤ j < i and arr[j] < arr[i])
이를 이용한 풀이는 다음과 같습니다.
import java.io.*;
import java.util.*;
/**
* LIS - DP로 풀기
*/
public class Main {
static int N;
static int[] arr;
public static void main(String[] args) throws IOException {
setting(); // 입력 세팅
List<Integer> sequence = solve();
// 결과 출력
StringBuilder sb = new StringBuilder();
sb.append(sequence.size()).append("\n");
for (Integer elem : sequence) {
sb.append(elem).append(" ");
}
System.out.println(sb);
}
private static List<Integer> solve() {
int[] dp = new int[N]; // 더 큰 길이를 저장하기 위한 dp 배열
int[] prev = new int[N]; // LIS에 속하는 원소를 추적하기 위한 prev 배열
Arrays.fill(dp, 1); // 초기 길이 1로 초기화
Arrays.fill(prev, -1); // 이전 인덱스가 없음을 확인하기 위해 -1로 초기화
int maxLength = 0; // 가장 긴 배열의 길이 저장
int maxIdx = -1; // 가장 긴 배열의 마지막 인덱스 저장. 추후 원소들을 추적할 때 사용
// 0 ~ N - 1까지의 원소를 범위를 넓혀가며 순회하는데
for (int i = 0; i < N; i++) {
// i 이전의 원소들 중에서
for (int j = 0; j < i; j++) {
// j 원소보다 i원소가 크고, j 원소를 마지막으로 하는 수열의 길이에 i 원소를 추가하는 방법이 더 길면
if (arr[j] < arr[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1; // arr[i]를 마지막 원소로 하는 LIS의 길이 업데이트
prev[i] = j; // 배열을 추적하기 위해 직전 원소의 인덱스를 저장
}
}
// i까지의 길이가 가장 길면
if (dp[i] > maxLength) {
maxLength = dp[i];
maxIdx = i; // 마지막 인덱스를 maxIdx로 업데이트
}
}
// 배열을 추적해 복원하기 위한 과정
List<Integer> seq = new ArrayList<>();
// maxIdx가 -1 즉 다음 인덱스가 없을 때까지 iterating
while (maxIdx != -1) {
seq.add(arr[maxIdx]); // 리스트에 추가
maxIdx = prev[maxIdx];
}
Collections.reverse(seq); // 리스트에 역순으로 저장되어 있음
return seq;
}
private static void setting() throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
N = Integer.parseInt(br.readLine());
arr = new int[N];
StringTokenizer st = new StringTokenizer(br.readLine());
for (int i = 0; i < N; i++) {
arr[i] = Integer.parseInt(st.nextToken());
}
}
}앞서 위에서 언급한 것처럼 n의 범위가 작다면 위의 방법으로도 LIS를 풀 수 있습니다. 하지만 n의 범위가 백만이 된다면 이중 반복문을 사용한 시간복잡도 O(n²)의 방법으로는 풀 수 없겠죠. 그래서 한 가지 아이디어가 더 들어갑니다.
앞에서 살펴본 O(n²) DP 방식은 모든 원소를 비교해야 하므로 N이 클 경우 성능이 좋지 않습니다. 이를 해결하기 위해 DP + 이분 탐색을 결합한 O(n log n) LIS 알고리즘을 활용할 수 있습니다.
패러다임을 전환해 봅시다. 기존 DP에서는 dp[i]를 이용해 LIS 길이를 구했지만, 이번에는 최적화를 위해 LIS의 길이를 유지하면서 가장 작은 값으로 갱신하는 방식을 사용해봅시다. 즉 핵심 개념은 다음과 같습니다.
•
같은 길이를 갖는 LIS 중에서 마지막 값을 가장 작은 값으로 유지하면 이후에 더 긴 LIS를 만들 수 있음
•
이를 위해 이분 탐색을 활용해 LIS 배열의 적절한 위치를 찾아 값을 업데이트함
•
LIS 배열을 따로 유지하면서, 기존 값 대신 더 작은 값을 유지하는 방식을 사용하면 최적의 LIS 길이를 만들 수 있음
이해가 될까요. 예를 들어, A = {10, 20, 15, 25, 11, 12, 13, 23, 24, 50} 배열이 있을 때 위의 아이디어를 적용해 풀어보면 단계는 다음과 같습니다. 아래의 LIS배열은 DP배열의 역할을 합니다.
•
index: 0, 입력값: 10, LIS = {10} ⇒ 첫 번째 원소는 그냥 추가
•
index: 1, 입력값: 20, LIS = {10, 20} ⇒ 마지막 원소보다 큰 값이므로 20을 추가 (LIS 길이 확장)
•
index: 2, 입력값: 15, LIS = {10, 15} ⇒ 20을 15로 대체 (더 작은 값을 유지)
•
index: 3, 입력값: 25, LIS = {10, 15, 25} ⇒ 마지막 원소보다 큰 값이므로 25를 추가 (LIS 길이 확장)
•
index: 4, 입력값: 11, LIS = {10, 11, 25} ⇒ 15를 11로 대체
•
index: 5, 입력값: 12, LIS = {10, 11, 12} ⇒ 25를 12로 대체
•
index: 6, 입력값: 13, LIS = {10, 11, 12, 13} ⇒ 마지막 원소보다 큰 값이므로 13을 추가 (LIS 길이 확장)
•
index: 7, 입력값: 23, LIS = {10, 11, 12, 13, 23} ⇒ 마지막 원소보다 큰 값이므로 23을 추가 (LIS 길이 확장)
•
index: 8, 입력값: 24, LIS = {10, 11, 12, 13, 23, 24} ⇒ 마지막 원소보다 큰 값이므로 24을 추가 (LIS 길이 확장)
•
index: 9, 입력값: 50, LIS = {10, 11, 12, 13, 23, 24, 50} ⇒ 마지막 원소보다 큰 값이므로 50을 추가 (LIS 길이 확장)
•
최종 LIS 길이는 length(DP) = 7
여기서 핵심은 LIS배열의 각 부분 배열은 LIS길이에서 올 수 있는 원소의 최솟값들로 유지된다는 것입니다. 마지막 LIS 배열을 보면 LIS = {10, 11, 12, 13, 23, 24, 50}입니다. LIS길이가 2일 때 LIS를 구성할 수 있는 배열은 {10, 20}, {10, 15}, {15, 25} 등등 많지만 그 중에서 가장 최솟값들로 구성된 {10, 11}이 최종 LIS배열에 있습니다. 또 LIS길이가 4일 때는 {10, 20, 25, 50}, {10, 23, 24, 50}, {10, 11, 12, 13} 등등이 있지만 최종적으로는 {10, 11, 12, 13}이 최종 LIS배열을 구성하고 있습니다.
따라서 같은 길이를 갖는 LIS 중에서 마지막 값을 가장 작은 값으로 계속 유지하고 있으면 다음에 어떤 값이 들어와도 최적의 위치를 찾을 수 있게 됩니다.
또 입력값이 LIS 배열의 어디에 들어가야 하는지를 판단하기 위해서는 이분 탐색을 사용하면 됩니다.
이를 코드로 구현하면 다음과 같습니다.
import java.io.*;
import java.util.*;
/**
* LIS - DP + 이분탐색으로 풀기
*/
public class Main {
static int N;
static int[] arr;
public static void main(String[] args) throws IOException {
setting(); // 입력 세팅
List<Integer> sequence = solve();
// 결과 출력
StringBuilder sb = new StringBuilder();
sb.append(sequence.size()).append("\n");
for (Integer elem : sequence) {
sb.append(elem).append(" ");
}
System.out.println(sb);
}
private static List<Integer> solve() {
List<Integer> LIS = new ArrayList<>(); // LIS 배열 초기화
// 원소들을 순회하면서
for (int num : arr) {
int idx = Collections.binarySearch(LIS, num); // 배열 안에 원소가 들어갈 위치를 찾기 위해 이분탐색
if (idx < 0) idx = -(idx + 1); // lower_bound 위치 찾기
if (idx == LIS.size()) LIS.add(num); // idx가 배열의 인덱스보다 더 큰 인덱스를 가리키면 배열에 추가
else LIS.set(idx, num); // 아니면 배열 안에 해당 요소를 업데이트
}
return LIS;
}
private static void setting() throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
N = Integer.parseInt(br.readLine());
arr = new int[N];
StringTokenizer st = new StringTokenizer(br.readLine());
for (int i = 0; i < N; i++) {
arr[i] = Integer.parseInt(st.nextToken());
}
}
}
위의 최종 LIS 배열 원소들은 사실 정답이 되는 실제 원소가 아닐 수 있습니다. 왜냐하면 각 LIS 길이 중 최솟값들로만 구성되어 있는 배열이기 때문이죠. 예를 들어 A = {10, 100, 1000, 11}라는 배열에서 최종 LIS 배열은 LIS = {10, 11, 1000}가 됩니다. 왜냐하면 LIS 길이가 2일 때 최소 원소들로 구성된 부분 배열은 {10, 11}이니까요. 하지만 정답은 {10, 100, 1000}죠. 11은 1000 뒤에 있기 때문에 정답의 일부가 될 수 없습니다.
따라서 정답을 구성하는 원소를 찾아내려면 추가적으로 원소를 추적할 두 개의 배열이나 리스트가 필요합니다.
import java.io.*;
import java.util.*;
/**
* LIS - DP + 이분탐색으로 풀기2
*/
public class Main {
static int N;
static int[] arr;
public static void main(String[] args) throws IOException {
setting(); // 입력 세팅
List<Integer> sequence = solve();
// 결과 출력
StringBuilder sb = new StringBuilder();
sb.append(sequence.size()).append("\n");
for (Integer elem : sequence) {
sb.append(elem).append(" ");
}
System.out.println(sb);
}
private static List<Integer> solve() {
List<Integer> LIS = new ArrayList<>(); // LIS 리스트 초기화
int[] prev = new int[N]; // LIS에 속하는 원소를 추적하기 위한 prev 배열
List<Integer> indexList = new ArrayList<>(); // LIS 리스트에 속한 원소의 실제 arr에서의 인덱스 저장
Arrays.fill(prev, -1); // 이전 인덱스가 없음을 확인하기 위해 -1로 초기화
// 원소들을 순회하면서
for (int i = 0; i < N; i++) {
int idx = Collections.binarySearch(LIS, arr[i]); // 배열 안에 원소가 들어갈 위치를 찾기 위해 이분탐색
if (idx < 0) idx = -(idx + 1); // lower_bound 위치 찾기
if (idx == LIS.size()) { // idx가 배열의 인덱스보다 더 큰 인덱스를 가리키면 어느 값보다도 큰 값이란 뜻
LIS.add(arr[i]); // 배열에 추가
indexList.add(i); // indexList에도 동일한 위치에 i도 저장
}
else { // 아니면
LIS.set(idx, arr[i]); // 배열 안에 해당 원소를 업데이트
indexList.set(idx, i); // indexList에도 동일한 위치에 i로 업데이트
}
// LIS 리스트에 첫번째로 추가된 값을 제외하고
if (idx > 0) prev[i] = indexList.get(idx - 1); // LIS배열에 idx - 1에 저장되어 있는 인덱스 저장
}
// LIS 원소 역추적
List<Integer> lisSequence = new ArrayList<>();
int lastIdx = indexList.get(indexList.size() - 1); // LIS에 저장된 마지막 인덱스 가져오기
while (lastIdx != -1) { // 가장 처음으로 돌아갈 때까지
lisSequence.add(arr[lastIdx]);
lastIdx = prev[lastIdx]; // 이전 인덱스로 업데이트
}
Collections.reverse(lisSequence); // 마지막부터 거꾸로 삽입했으므로 다시 바꾸기
return lisSequence;
}
private static void setting() throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
N = Integer.parseInt(br.readLine());
arr = new int[N];
StringTokenizer st = new StringTokenizer(br.readLine());
for (int i = 0; i < N; i++) {
arr[i] = Integer.parseInt(st.nextToken());
}
}
}
LIS 배열에 저장된 원소들의 원래 arr 배열에서의 인덱스를 저장하기 위한 prev 배열과 LIS 배열의 값의 원소값의 원래 arr 배열에서의 인덱스를 저장하기 위한 indexList 리스트가 추가됐습니다.

