본문으로 건너뛰기
김신건의 로그

최장 공통 부분 수열 (Longest Common Subsequence)

· 수정 · 📖 약 3분 · 885자/단어 #algorithm #dp #lcs #string
LCS, 최장 공통 부분 수열, longest common subsequence, lcs

정의

최장 공통 부분 수열 (Longest Common Subsequence, LCS) 은 두 수열 A, B 에서 순서를 유지하며 공통으로 등장하는 부분 수열 중 가장 긴 것을 찾는 문제.

예: A = ABCBDAB, B = BDCAB → LCS = BCAB, 길이 4.

부분 수열은 연속하지 않아도 됨 (부분 문자열과 다름). 유전자 염기서열 비교, diff 알고리즘, 텍스트 유사도 측정에 핵심.

문제 상황과 동기

길이 N, M 인 두 수열의 LCS 길이를 구한다.

  • naive: 모든 부분집합 조합 2^N × 2^M. N, M > 20 불가.
  • DP: O(NM) - 2차원 테이블로 점화식 해결.

핵심 통찰: A[i] 와 B[j] 가 같으면 둘 다 포함, 다르면 둘 중 하나 제외한 최대.

PS: 문자열 편집 거리, 버전 관리 (git diff), 생물정보학 (sequence alignment).

시각화

핵심 아이디어

dp[i][j] = A[0..i-1] 과 B[0..j-1] 의 LCS 길이.

점화식:

dp[i][j] = 
  if i == 0 or j == 0: 0
  elif A[i-1] == B[j-1]: dp[i-1][j-1] + 1
  else: max(dp[i-1][j], dp[i][j-1])

최종 답 = dp[N][M].

수열 복원 (Traceback)

dp 테이블을 역추적:

i = N, j = M
while i > 0 and j > 0:
  if A[i-1] == B[j-1]:
    result.push(A[i-1])
    i--, j--
  elif dp[i-1][j] > dp[i][j-1]:
    i--
  else:
    j--
reverse(result)

알고리즘

lcs(A, B):
    N = len(A), M = len(B)
    dp[0..N][0..M] = 0
    for i = 1..N:
        for j = 1..M:
            if A[i-1] == B[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[N][M]

구현

// LCS O(NM), traceback 포함
#include <bits/stdc++.h>
using namespace std;
int main() {
  string a, b;
  cin >> a >> b;
  int n = a.size(), m = b.size();
  vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
  for (int i = 1; i <= n; i++) {
      for (int j = 1; j <= m; j++) {
          if (a[i-1] == b[j-1])
              dp[i][j] = dp[i-1][j-1] + 1;
          else
              dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
      }
  }
  cout << dp[n][m] << "\n";
  // traceback
  string lcs;
  int i = n, j = m;
  while (i > 0 && j > 0) {
      if (a[i-1] == b[j-1]) {
          lcs.push_back(a[i-1]);
          i--; j--;
      } else if (dp[i-1][j] > dp[i][j-1]) i--;
      else j--;
  }
  reverse(lcs.begin(), lcs.end());
  cout << lcs << "\n";
}
stdin
ABCBDAB
BDCAB
결과
4
BCAB

복잡도

항목
시간 (최선)O(NM)
시간 (평균)O(NM)
시간 (최악)O(NM)
공간O(NM) (최적화: O(min(N, M)) 가능)
수열 복원✓ (traceback O(N + M))

변형 / 활용

공간 최적화 O(min(N, M))

현재 행만 유지. 단, traceback 불가.

vector<int> prev(m + 1, 0), curr(m + 1, 0);
for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
        if (a[i-1] == b[j-1])
            curr[j] = prev[j-1] + 1;
        else
            curr[j] = max(prev[j], curr[j-1]);
    }
    swap(prev, curr);
}
return prev[m];

Longest Common Substring (연속)

LCS 는 부분 수열 (연속 X). 부분 문자열 (substring) 은 다른 DP.

dp[i][j] = 
  if A[i-1] == B[j-1]: dp[i-1][j-1] + 1
  else: 0

Edit Distance (Levenshtein)

LCS 와 유사하나, 삽입 / 삭제 / 치환 비용 포함.

dp[i][j] = min(
  dp[i-1][j] + 1,      # delete
  dp[i][j-1] + 1,      # insert
  dp[i-1][j-1] + (A[i-1] != B[j-1])  # replace
)

3-way LCS

세 수열 A, B, C 의 LCS. O(NML) 3차원 DP.

dp[i][j][k] = 
  if A[i-1] == B[j-1] == C[k-1]: dp[i-1][j-1][k-1] + 1
  else: max(dp[i-1][j][k], dp[i][j-1][k], dp[i][j][k-1])

함정

1. 1-indexed vs 0-indexed

dp[i][j] 는 A[0..i-1], B[0..j-1] 의 LCS. 인덱스 헷갈림 주의.

2. 부분 수열 vs 부분 문자열

LCS 는 연속하지 않아도 됨. 연속 부분 문자열은 다른 문제 (Longest Common Substring).

3. traceback 방향

dp 테이블을 (N, M) 에서 역으로 (0, 0) 까지. 결과는 reverse 필요.

4. 공간 최적화시 traceback 불가

O(min(N, M)) 공간 최적화는 길이만 구할 수 있음. 수열 복원은 O(NM) 테이블 필요.

BOJ 연습 문제

번호제목정답률링크
BOJ 9251LCS-kokoa-lab
BOJ 9252LCS 2-kokoa-lab
BOJ 1958LCS 3-kokoa-lab
BOJ 5582공통 부분 문자열-kokoa-lab

참고

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기