Longest Common Subsequence (최장 공통 부분 수열)
두 문자열에서 문자의 상대적인 순서를 유지하면서 공통으로 만들 수 있는 가장 긴 Subsequence (부분 수열)의 길이를 구한다.
n인 문자열은 총 2^n개의 부분 수열을 가짐예:
s1 = "AGGTAB"
s2 = "GXTXAYB"
LCS = "GTAB"
length = 4
두 문자열의 길이를 각각 m, n이라 하고 마지막 문자를 비교한다.
s1[m-1] == s2[n-1]
해당 문자는 LCS에 포함시킬 수 있으므로
LCS(m, n) = 1 + LCS(m-1, n-1)
둘 중 하나는 버려야 한다.
LCS(m, n)
= max(
LCS(m-1, n),
LCS(m, n-1)
)
둘 중 하나가 빈 문자열이면:
LCS(0, n) = 0
LCS(m, 0) = 0
이 Recurrence Relation (점화식)이 모든 풀이의 핵심이다.
점화식을 그대로 재귀로 구현한다.
문자가 다를 때마다
(m-1, n)
(m, n-1)
두 갈래로 분기한다.
같은 (m, n) 상태를 여러 번 계산하는 Overlapping Subproblems (중복 부분 문제)가 발생한다.
따라서 입력이 커지면 비효율적이다.
재귀 결과 LCS(m, n)을 memo[m][n]에 저장한다.
이미 계산됨 → 바로 반환
아직 없음 → 계산 후 저장
가능한 상태는
0 <= i <= m
0 <= j <= n
이므로 최대 (m+1)(n+1)개.
O(mn)O(mn) + 재귀 스택핵심은 중복 계산 제거.
dp[i][j]를 다음과 같이 정의한다.
s1의 처음i개 문자와s2의 처음j개 문자의 LCS 길이
dp[0][j] = 0
dp[i][0] = 0
빈 문자열과의 LCS는 항상 0.
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
최종 답:
dp[m][n]
O(mn)O(mn)dp[i][j] 계산에 필요한 값은 사실 세 개뿐이다.
dp[i-1][j] // 위
dp[i][j-1] // 왼쪽
dp[i-1][j-1] // 왼쪽 위
따라서 모든 행을 저장할 필요가 없다.
dp[j]를 갱신하기 전:
dp 0 1 2 3 4 5 ..
0 i-1,j-1 i-1,j
1 i, j-1 i,j
2
3
4
5
dp[j] = 이전 행의 dp[i-1][j]
dp[j-1] = 현재 행의 dp[i][j-1]
prev = 이전 행의 dp[i-1][j-1]
그러므로:
if match:
dp[j] = prev + 1
else:
dp[j] = max(dp[j], dp[j-1])
단, dp[j]를 덮어쓰기 전에 기존 값을 temp에 보관하고 다음 반복의 prev로 넘겨야 한다.
temp = dp[j]
현재 dp[j] 계산
prev = temp
O(mn)O(n)O(min(m,n)) dp[i][j]
= s1[0..i-1], s2[0..j-1]의 LCS 길이
같으면:
dp[i][j] = dp[i-1][j-1] + 1
다르면:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
LCS는 대표적인 Dynamic Programming (동적 계획법) 문제이며,
2개의 문자열 Prefix (접두 구간)를 상태로 만들고, 마지막 문자의 일치 여부에 따라 상태를 줄인다.
라는 관점이 핵심이다.
| 방법 | Time | Space |
|---|---|---|
| Naive Recursion (단순 재귀) | Exponential | 재귀 스택 |
| Memoization (메모이제이션) | O(mn) | O(mn) |
| Bottom-Up DP (상향식 DP) | O(mn) | O(mn) |
| Space Optimized DP (공간 최적화) | O(mn) | O(min(m,n)) |