Skip to content

최장 증가 수열 (LIS) ​

최장 증가 수열(LIS, Longest Increasing Subsequence)은 배열에서 순서를 지키며 몇 개를 골랐을 때, 값이 계속 커지는 가장 긴 부분 수열을 찾는 문제입니다.

한눈에 보기 ​

최장 증가 수열: 가장 긴 증가하는 부분 수열

  • 부분 수열: 원래 순서는 그대로 두고, 중간 원소를 건너뛰며 골라낸 수열입니다. 붙어 있을 필요는 없습니다.
  • 증가: 앞의 값보다 뒤의 값이 커야 합니다. (이 글은 같은 값을 허용하지 않는 "엄격한 증가" 기준)

비유하면, 줄 서 있는 사람들 중 몇 명만 남겨서 앞에서 뒤로 갈수록 키가 커지게 만들고 싶을 때, 최대 몇 명까지 남길 수 있는지 묻는 문제입니다. 자리를 바꿀 수는 없고 빼기만 할 수 있습니다.

[7, 2, 3, 8, 4, 5] → 이 배열에서는 [2, 3, 4, 5]가 LIS로 답은 4입니다.

[2, 3, 8]도 증가하는 부분 수열이지만 길이가 3이라 가장 길지는 않습니다.

구현 방법 (시간복잡도) ​

  1. DP: O(N^2)
  2. Lower Bound(이분 탐색): O(N log N)

동작 과정 ​

DP 방식 ​

동적 계획법으로 "dp[i] = arr[i]로 끝나는 가장 긴 증가 수열의 길이"를 채웁니다.

  1. 모든 dp[i]를 1로 시작합니다. (자기 자신 하나만 있어도 길이 1)
  2. i보다 앞에 있는 j를 모두 보면서, arr[j] < arr[i]이면 arr[j]로 끝나는 수열 뒤에 arr[i]를 붙일 수 있습니다. 그러면 길이는 dp[j] + 1입니다.
  3. 그중 가장 큰 값을 dp[i]로 정합니다.
  4. 마지막에 dp 전체의 최댓값이 답입니다.

arr = [7, 2, 3, 8, 4, 5]

iarr[i]앞에서 이어 붙일 수 있는 값dp[i]
07없음1
12없음1
232 (dp=1)2
387 (dp=1), 2 (dp=1), 3 (dp=2)3
442 (dp=1), 3 (dp=2)3
552 (dp=1), 3 (dp=2), 4 (dp=3)4

dp = [1, 1, 2, 3, 3, 4] → 최댓값 4.

원문 코드는 dp를 0으로 시작해서 저장된 값이 [0, 0, 1, 2, 2, 3]이고, "dp 배열에 저장된 값 중 최댓값 + 1"을 답으로 냅니다. 모든 칸이 1씩 작을 뿐 같은 방법입니다.

Lower Bound 방식 ​

하지만 N^2으로 해결할 수 없는 문제라면? (예: 배열 길이가 최대 10만이면 10만 × 10만 = 100억 번 비교)

이때는 Lower Bound를 활용한 LIS 구현을 해야 합니다. Lower Bound는 정렬된 배열에서 x 이상인 값이 처음 나오는 위치를 이분 탐색으로 찾는 것입니다.

tails[k] = "길이가 k+1인 증가 수열들 중 가장 작은 끝값"을 관리합니다. 끝값이 작을수록 뒤에 더 많은 값을 붙일 수 있으니 유리합니다. tails는 항상 오름차순이라 이분 탐색이 가능합니다.

  1. 값 x를 하나씩 봅니다.
  2. tails에서 x의 lower bound 위치를 찾습니다.
  3. 위치가 tails 끝이면 x가 모든 끝값보다 크다는 뜻이므로, 뒤에 붙여 수열을 한 칸 늘립니다.
  4. 아니면 그 자리의 끝값을 더 작은 x로 바꿉니다. (같은 길이인데 끝값이 더 작은 수열을 찾은 것)
  5. 끝나면 tails의 길이가 답입니다.
xlower bound 위치tails설명
70[7]비어 있으니 추가
20[2]7 자리를 더 작은 2로 교체
31[2, 3]끝이므로 추가
82[2, 3, 8]끝이므로 추가
42[2, 3, 4]8 자리를 4로 교체
53[2, 3, 4, 5]끝이므로 추가

tails의 길이 4가 답입니다.

아래 애니메이션은 같은 생각을 길이별 후보 수열로 보여 줍니다. 값을 하나씩 읽으면서 길이마다 "끝값이 가장 작은 증가 수열" 하나만 남기고, 같은 길이인데 끝값이 더 큰 후보는 지웁니다(Inactive). 각 줄의 끝값만 모으면 위의 tails가 됩니다.

수열을 읽으며 길이별로 끝값이 가장 작은 증가 수열만 남겨 LIS 길이를 구하는 과정

그림 출처: Wikimedia Commons (CC BY-SA 4.0, Shiyu Ji)

주의: tails는 길이를 구하기 위한 도구일 뿐, 실제 LIS 그 자체가 아닐 수 있습니다. 예를 들어 [3, 4, 1]이면 tails는 [1, 4]가 되지만, 1은 4보다 뒤에 있으므로 실제 수열이 아닙니다. (실제 LIS는 [3, 4])

Python 코드 ​

python
from bisect import bisect_left


# 1. DP 방식: O(N^2)
def lis_dp(arr):
    if not arr:
        return 0
    n = len(arr)
    dp = [1] * n                       # dp[i] = arr[i]로 끝나는 LIS 길이 (자기 자신만 있어도 1)
    for i in range(1, n):
        for j in range(i - 1, -1, -1):
            if arr[i] > arr[j]:        # 2. 앞의 더 작은 값 뒤에 이어 붙일 수 있으면
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)                     # 3. 그중 가장 긴 것


# 4. 이분 탐색(Lower Bound) 방식: O(N log N)
def lis_lower_bound(arr):
    tails = []                         # tails[k] = 길이 k+1인 증가 수열의 가장 작은 끝값
    for x in arr:
        pos = bisect_left(tails, x)    # 5. x 이상이 처음 나오는 위치 (lower bound)
        if pos == len(tails):
            tails.append(x)            # 6. 모든 끝값보다 크면 수열을 한 칸 늘림
        else:
            tails[pos] = x             # 7. 아니면 그 자리의 끝값을 더 작은 x로 교체
    return len(tails)


arr = [7, 2, 3, 8, 4, 5]
print(lis_dp(arr))            # 출력: 4
print(lis_lower_bound(arr))   # 출력: 4
  1. 원문과 같은 이중 반복문입니다. dp를 1로 시작하므로 마지막에 +1 할 필요가 없습니다. 빈 배열이면 0을 돌려줍니다.
  2. arr[j]로 끝나는 수열 뒤에 arr[i]를 붙이면 길이가 dp[j] + 1이 되므로, 지금까지 찾은 값과 비교해 더 긴 쪽을 남깁니다.
  3. LIS는 어느 위치에서 끝날지 모르니 dp 전체의 최댓값이 답입니다.
  4. Lower Bound 방식입니다. tails는 항상 오름차순으로 유지됩니다.
  5. bisect_left는 Python 표준 라이브러리의 lower bound 함수입니다.
  6. x가 가장 크면 가장 긴 수열 뒤에 붙일 수 있으므로 길이가 1 늘어납니다.
  7. 그렇지 않으면 같은 길이의 수열 끝값을 x로 낮춰, 뒤에 올 값들이 붙기 쉽게 만듭니다.

실제 수열까지 구하기 ​

길이뿐 아니라 LIS 자체가 필요하면, DP 방식에서 "누구 뒤에 붙었는지"를 함께 기록해 두었다가 거꾸로 따라가면 됩니다. (원문에는 없는 보충 내용)

python
def lis_with_path(arr):
    n = len(arr)
    if n == 0:
        return []
    dp = [1] * n
    prev = [-1] * n                    # 1. prev[i] = arr[i] 바로 앞에 온 원소의 위치
    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i] and dp[j] + 1 > dp[i]:
                dp[i] = dp[j] + 1
                prev[i] = j
    i = dp.index(max(dp))              # 2. 가장 긴 수열이 끝나는 위치에서 출발
    path = []
    while i != -1:                     # 3. prev를 거꾸로 따라가며 모으기
        path.append(arr[i])
        i = prev[i]
    return path[::-1]


print(lis_with_path([7, 2, 3, 8, 4, 5]))  # 출력: [2, 3, 4, 5]
  1. dp[i]가 늘어날 때마다 "어느 j 뒤에 붙었는지"를 prev[i]에 적습니다. -1은 "앞에 아무도 없음"입니다.
  2. 가장 긴 길이를 가진 위치가 LIS의 마지막 원소입니다.
  3. 끝에서부터 prev를 따라가면 수열이 거꾸로 모이므로, 마지막에 뒤집습니다.

시간복잡도와 공간복잡도 ​

방법최선평균최악추가 공간
DPO(N^2)O(N^2)O(N^2)O(N)
Lower BoundO(N)O(N log N)O(N log N)O(N)
  • DP 방식은 값과 상관없이 모든 (i, j) 쌍을 비교하므로 항상 O(N^2)입니다.
  • Lower Bound 방식은 원소 N개마다 이분 탐색 O(log N)을 하므로 O(N log N)입니다. 배열이 계속 작아지기만 하면 tails 길이가 1에 머물러 탐색이 바로 끝나므로 최선은 O(N)입니다.
  • N = 10만일 때 N^2은 100억, N log N은 약 170만이라 차이가 매우 큽니다. 표기법은 시간 복잡도 글을 참고하세요.

활용 예 ​

  • 백준 11053 "가장 긴 증가하는 부분 수열": N이 작아 DP 방식으로 충분합니다.
  • 백준 12015 "가장 긴 증가하는 부분 수열 2": N이 커서 Lower Bound 방식이 필요합니다.
  • 백준 2565 "전깃줄": 한쪽 전봇대 번호 순으로 정렬한 뒤, 다른 쪽 번호의 LIS를 구하면 "겹치지 않게 남길 수 있는 최대 전깃줄 수"가 됩니다.
  • 같은 값도 허용하는 "감소하지 않는 수열"을 구하려면 bisect_left 대신 bisect_right를, DP에서는 > 대신 >=를 씁니다.

정리 ​

개념비전공자 버전 요약
LIS순서를 지키며 골랐을 때 점점 커지는 가장 긴 줄
부분 수열자리는 그대로, 중간을 건너뛰며 골라낸 수열
DP 방식"여기서 끝나는 가장 긴 줄"을 앞에서부터 모두 계산, O(N^2)
Lower Bound 방식길이별 가장 작은 끝값만 기억하고 이분 탐색으로 갱신, O(N log N)
tails 배열길이를 세는 도구일 뿐 실제 LIS와 다를 수 있음