최장 증가 부분 수열 (LIS, Longest Increasing Subsequence)

한눈에 보기

수열에서 가장 긴 증가 부분 수열을 찾는 LIS 문제를 다룬다. O(n^2) 동적 계획법과 이분 탐색을 결합한 O(n log n) 풀이, tails 배열의 의미와 수열 복원 방법을 정리한다.

최종 업데이트 2026.09.17

최장 증가 부분 수열(LIS, Longest Increasing Subsequence)은 주어진 수열에서 순서를 유지한 채 일부를 골라 만든 증가 수열 중 가장 긴 것을 찾는 문제다. 연속일 필요는 없고 순서만 지키면 된다.

DP의 기본기를 확인하는 문제이자, 같은 문제를 O(n²)과 O(n log n) 두 방법으로 푸는 대표 사례다. 코딩테스트에서는 n의 크기를 보고 어느 쪽이 필요한지 판단해야 한다. n이 1,000 정도면 O(n²), 100만이면 반드시 O(n log n)이다.

무엇을 구하는 문제인가

최장 증가 부분 수열 LIS: 수열 10 9 2 5 3 7 101 18에서 길이 4인 증가 부분 수열 2 3 7 18을 골라낸 그림
답이 유일하지는 않다. 2 → 5 → 7 → 101도 길이 4로 똑같이 정답이다. 문제가 묻는 것은 보통 길이이고, 수열 자체를 복원하라는 문제는 별도 처리가 필요하다.

O(n²) 방법 — 기본 DP

상태를 dp[i] = "i번째 원소로 끝나는 증가 부분 수열의 최대 길이"로 잡는다. "i번째까지 봤을 때의 최대 길이"가 아니라 i로 끝난다는 조건이 붙는 것이 핵심이다. 그래야 다음 원소를 이어붙일 수 있는지 판단할 수 있다.

i번째 원소 앞에 올 수 있는 것은 arr[j] < arr[i]를 만족하는 j들이다. 그중 가장 긴 것 뒤에 붙이면 된다.

dp[i] = max(dp[j] + 1), 단 j < i이고 arr[j] < arr[i]. 그런 j가 없으면 dp[i] = 1이다.

답은 dp[n-1]이 아니라 max(dp)다. LIS가 마지막 원소로 끝난다는 보장이 없기 때문이다. 이 실수가 정말 흔하다.

O(n log n) 방법 — 이분 탐색

더 빠른 방법은 tails라는 배열 하나를 관리하는 것이다. tails[k]의 의미는 "길이가 k+1인 증가 부분 수열을 만들 수 있는 것 중, 가능한 가장 작은 마지막 값"이다.

끝값이 작을수록 뒤에 더 많은 수를 이어붙일 수 있으니 유리하다. 그래서 같은 길이라면 끝값이 최소인 것만 기억해두면 된다.

새 원소 x를 만나면 두 가지 중 하나를 한다.

  • tails의 모든 값보다 x가 크면 → 뒤에 추가한다. LIS 길이가 1 늘어난 것이다.
  • 그렇지 않으면 → x 이상인 첫 자리를 x로 교체한다. 길이는 그대로지만 끝값이 작아져 앞으로가 유리해진다.

"x 이상인 첫 자리"를 찾는 것이 곧 lower bound이고, tails는 항상 정렬 상태를 유지하므로 이분 탐색이 가능하다. 원소마다 O(log n)이라 전체 O(n log n)이다.

최장 증가 부분 수열 LIS: 수열을 하나씩 처리하며 tails 배열이 갱신되는 여덟 단계
보라색은 길이가 늘어난 순간, 빨간색은 끝값만 작아진 순간이다. 마지막 tails의 길이 4가 곧 LIS의 길이다.

주의할 점이 하나 있다. 마지막 tails 배열이 [2, 3, 7, 18]이고 이것이 실제 LIS와 우연히 일치하지만, 일반적으로 tails는 실제 LIS가 아니다. tails는 여러 시점의 최소 끝값이 뒤섞인 배열이라 하나의 부분 수열을 이루지 않을 수 있다. 길이만 정확하다. 실제 수열이 필요하면 아래처럼 따로 복원해야 한다.

시간 복잡도

방법시간공간쓸 수 있는 n
기본 DPO(n²)O(n)대략 10,000 이하
이분 탐색O(n log n)O(n)100만도 가능

기본 DP는 느리지만 점화식이 직관적이고 변형에 강하다. "합이 최대인 증가 부분 수열", "가장 긴 바이토닉 부분 수열" 같은 변형 문제는 O(n²) DP가 오히려 편하다.

구현 코드 예시

def lis_dp(arr):
    """O(n^2) — dp[i]는 i번째 원소로 끝나는 LIS의 길이."""
    if not arr:
        return 0
    n = len(arr)
    dp = [1] * n

    for i in range(n):
        for j in range(i):
            if arr[j] < arr[i]:
                dp[i] = max(dp[i], dp[j] + 1)

    return max(dp)          # dp[n-1]이 아니다


import bisect

def lis_fast(arr):
    """O(n log n) — tails[k]는 길이 k+1 수열의 가능한 최소 끝값."""
    tails = []
    for x in arr:
        i = bisect.bisect_left(tails, x)    # x 이상이 처음 나오는 자리
        if i == len(tails):
            tails.append(x)                 # 전부 x보다 작다 → 길이 +1
        else:
            tails[i] = x                    # 그 자리를 더 작은 x로 교체
    return len(tails)

bisect_left와 bisect_right의 선택이 문제 조건을 가른다. 같은 값을 허용하지 않는 증가 수열이면 bisect_left, 같은 값을 허용하는 비감소 수열이면 bisect_right다. 함수 이름 하나 차이로 답이 달라지니 문제에서 "증가"인지 "감소하지 않는"인지 꼭 확인한다.

실제 수열을 복원하려면 각 자리를 차지한 원소의 인덱스와 그 직전 원소를 함께 기록한다.

def lis_restore(arr):
    """LIS 수열 자체를 복원한다."""
    if not arr:
        return []

    tails, tail_idx = [], []          # tail_idx[k] = tails[k]를 만든 원소의 인덱스
    prev = [-1] * len(arr)            # prev[i] = LIS에서 i 앞에 오는 원소의 인덱스

    for i, x in enumerate(arr):
        p = bisect.bisect_left(tails, x)
        if p == len(tails):
            tails.append(x)
            tail_idx.append(i)
        else:
            tails[p] = x
            tail_idx[p] = i
        prev[i] = tail_idx[p - 1] if p > 0 else -1

    result, cur = [], tail_idx[-1]    # 마지막 자리를 차지한 원소부터 거슬러 올라간다
    while cur != -1:
        result.append(arr[cur])
        cur = prev[cur]
    return result[::-1]

자주 하는 실수

  • dp[n-1]을 답으로 반환한다. LIS가 마지막 원소로 끝난다는 보장이 없다. 반드시 max(dp)다.
  • tails를 실제 LIS로 착각한다. 길이만 맞는다. 수열이 필요하면 별도 복원 로직이 필요하다.
  • bisect_left와 bisect_right를 잘못 고른다. 중복 값이 있는 입력에서 답이 1씩 어긋난다. "증가"와 "비감소"를 구분한다.
  • 부분 수열과 부분 배열을 혼동한다. LIS는 연속일 필요가 없다. 연속을 요구하는 문제는 훨씬 쉬운 다른 문제다.
  • 빈 배열을 처리하지 않는다. max(dp)가 빈 리스트에서 예외를 던진다.
  • n을 보지 않고 O(n²)를 쓴다. n이 10만이면 100억 번 연산이라 무조건 시간 초과다. 제약 조건을 먼저 읽는다.

면접에서 이렇게 물어본다

  • LIS의 O(n²) 점화식을 세우고 상태의 의미를 설명해 보라.
  • 왜 dp[i]를 "i로 끝나는 LIS 길이"로 정의하는가.
  • O(n log n) 방법에서 tails 배열의 각 원소가 의미하는 바는 무엇인가.
  • tails가 실제 LIS와 다를 수 있는 이유를 설명해 보라.
  • 이분 탐색이 적용 가능한 이유, 즉 tails가 항상 정렬 상태인 이유는 무엇인가.
  • 같은 값을 허용하는 경우 구현이 어떻게 달라지는가.
  • 실제 LIS 수열을 복원하려면 무엇을 추가로 기록해야 하는가.
알고리즘 문서 전체 보기

이 개념, 내 코드에서는 어떻게 물어볼까요

데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.

공고 넣고 시작하기