최장 증가 부분 수열 (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)이다.
무엇을 구하는 문제인가
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)이다.
tails의 길이 4가 곧 LIS의 길이다.주의할 점이 하나 있다. 마지막 tails 배열이 [2, 3, 7, 18]이고 이것이 실제 LIS와 우연히 일치하지만, 일반적으로 tails는 실제 LIS가 아니다. tails는 여러 시점의 최소 끝값이 뒤섞인 배열이라 하나의 부분 수열을 이루지 않을 수 있다. 길이만 정확하다. 실제 수열이 필요하면 아래처럼 따로 복원해야 한다.
시간 복잡도
| 방법 | 시간 | 공간 | 쓸 수 있는 n |
|---|---|---|---|
| 기본 DP | O(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 수열을 복원하려면 무엇을 추가로 기록해야 하는가.
알고리즘에서 같이 읽기
- 이분 탐색 (Binary Search)정렬된 배열에서 탐색 범위를 절반씩 줄여 O(log n)에 값을 찾는 이분 탐색의 원리와 동작 과정을 그림으로 정리했다. lower bound와 upper bound, 코딩테스트 단골인 파라메트릭 서치와 회전 정렬 배열, 구현 코드와 자주 하는 실수까지 다룬다.
- 비트마스크 (Bitmask)정수의 각 비트로 집합의 포함 여부를 표현하는 비트마스크 기법을 다룬다. 기본 비트 연산 정리, 부분집합 열거, 외판원 순회 동적 계획법 구현과 주의점을 정리한다.
- DFS와 BFS (Depth First Search, Breadth First Search)그래프의 모든 정점을 방문하는 DFS와 BFS의 원리와 방문 순서 차이를 스택과 큐로 설명하고, O(V + E) 복잡도, 구현 코드, 문제 유형별 선택 기준을 정리한다.
- 다익스트라 알고리즘 (Dijkstra's Algorithm)음수 간선이 없는 그래프에서 한 정점으로부터의 최단 거리를 구하는 다익스트라 알고리즘의 그리디 원리, 우선순위 큐 구현, 복잡도 비교와 한계를 정리한다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기