두 문자열 s, t가 주어질 때, s로부터 t를 만들 수 있는 서로 다른 subsequence의 개수를 구하세요.
- subsequence: 문자의 순서는 유지하면서, 일부 문자를 생략한 것
- 각 문자는 한 번만 사용 가능
📌 예시 1
Input:
s = "rabbbit",
t = "rabbit"
Output: 3
- s에서 'b'가 3번 등장하기 때문에, 각각을 생략하는 방식으로 3가지 방법이 존재
📌 예시 2
Input:
s = "babgbag",
t = "bag"
Output: 5
- 다양한 'b', 'a', 'g' 조합이 존재해서 총 5가지 방법으로 만들 수 있음
🔧 핵심 개념: dp[i][j]의 의미
표현 | 의미 |
dp[i][j] | s[0:i]로 t[0:j]를 만들 수 있는 subsequence의 개수 |
즉, s의 앞 i글자를 사용해서 t의 앞 j글자를 만드는 경우의 수를 누적해서 저장하는 DP 테이블입니다.
🔄 점화식 (Recurrence)
✅ 1. 문자가 일치할 때: s[i-1] == t[j-1]
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
- dp[i-1][j-1]: 현재 문자를 사용해서 t[j-1]을 만듦
- dp[i-1][j]: 현재 문자를 건너뛰고, 이전 s로 t[0:j]을 만들던 경우 그대로 유지
✅ 2. 문자가 다를 때: s[i-1] != t[j-1]
dp[i][j] = dp[i-1][j]
- 현재 문자는 t에 사용할 수 없으므로, 무시하고 이전 s로 만들던 경우만 유지
🔹 초깃값 (Initialization)
- dp[i][0] = 1: 어떤 s든, 빈 문자열 t는 항상 1가지 방법으로 만들 수 있음 (아무 문자도 고르지 않음)
- dp[0][j] = 0: 빈 s로는 어떤 t도 만들 수 없음 → 0가지
✅ 전체 코드 + 상세 주석
class Solution:
def numDistinct(self, s: str, t: str) -> int:
M, N = len(s), len(t)
# dp[i][j] = s[0:i]로 t[0:j]를 만들 수 있는 subsequence 개수
dp = [[0] * (N + 1) for _ in range(M + 1)]
"""
# 초기화: t가 빈 문자열이면 어떤 s든 1가지 방법 존재 (문자 선택 안 하는 경우)
"""
for i in range(M + 1):
dp[i][0] = 1
# DP 테이블 채우기
"""
for loop 는 1부터..
"""
for i in range(1, M + 1): # s의 앞 i개 문자까지 사용
for j in range(1, N + 1): # t의 앞 j개 문자까지 만들기
if s[i - 1] == t[j - 1]:
# 문자 일치할 경우:
# 1. 현재 문자 사용해서 매칭 (dp[i-1][j-1])
# 2. 현재 문자 건너뛰고 이전 결과 유지 (dp[i-1][j])
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]
else:
# 문자 불일치 → 현재 문자 못 쓰고, 이전까지 결과만 가져옴
dp[i][j] = dp[i - 1][j]
# s 전체로 t 전체를 만드는 경우의 수가 정답
return dp[M][N]
👀 예시 흐름 (s = "babgbag", t = "bag")
i (s) | j (t) | s[i-1] | t[j-1] | dp[i][j] 계산 방식 |
3 | 2 | b | a | 불일치 → dp[2][2] |
4 | 2 | g | a | 불일치 → dp[3][2] |
5 | 2 | b | a | 불일치 → dp[4][2] |
6 | 2 | a | a | 일치 → dp[5][1] + dp[5][2] |
7 | 3 | g | g | 일치 → dp[6][2] + dp[6][3] |
이런 식으로 누적해서 정답이 쌓이게 됩니다.
🧾 시간 및 공간 복잡도
항목 | 복잡도 |
시간 | O(M × N) |
공간 | O(M × N) → O(N)으로 최적화 가능 |
이 문제는 문자열 s에서 문자열 t를 **부분 수열(subsequence)**로 만들 수 있는 서로 다른 방법의 수를 구하는 문제입니다.
S의 부분 수열 = 문자열 T를 만드는 수 찾는 것
이를 해결하기 위해 **동적 계획법(DP)**을 사용합니다.
문제 이해
- 부분 수열 (Subsequence):
- 문자열의 일부 문자들을 선택해 순서를 유지하면서 생성된 새로운 문자열.
- 예: s="rabbbit", t="rabbit"
- 부분 수열로 t를 생성하는 방법:
- 첫 번째 r, 첫 번째 a, 첫 번째 b, 두 번째 b, 첫 번째 i, 첫 번째 t.
- ...
- 총 3가지 방법.
- 부분 수열로 t를 생성하는 방법:
- 목표:
- s에서 t를 생성하는 모든 부분 수열의 개수를 반환.
시간 및 공간 복잡도
- 시간 복잡도:
- O(m×n): DP 테이블 크기에 비례.
- 공간 복잡도:
- O(m×n): DP 테이블 사용.
예제
class Solution:
def numDistinct(self, s: str, t: str) -> int:
cache = {} # 캐싱을 위한 딕셔너리 / Cache dictionary for memoization
def dfs(i, j):
# t의 모든 문자를 찾았으면 1을 반환
# If all characters in t are matched, return 1
if j == len(t):
return 1
# s가 끝났는데 t를 완성하지 못한 경우 0을 반환
# If s is fully traversed but t is not matched, return 0
if i == len(s):
return 0
# 캐시가 존재하면 캐시값 반환
# If the result for (i, j) is already computed, return it
if (i, j) in cache:
return cache[(i, j)]
# 두 문자가 같으면 두 가지 선택을 고려
# If the characters match, consider two choices:
if s[i] == t[j]:
# (1) 매칭하여 진행 / Match and move forward
# (2) 건너뛰고 진행 / Skip the character in s and move forward
cache[(i, j)] = dfs(i + 1, j + 1) + dfs(i + 1, j)
else:
# 두 문자가 다르면 s[i]를 건너뜀
# If the characters don't match, skip s[i]
cache[(i, j)] = dfs(i + 1, j)
# 결과를 캐시에 저장하고 반환
# Save the result in the cache and return it
return cache[(i, j)]
# dfs 함수 시작
# Start the recursive DFS from the beginning of both strings
return dfs(0, 0)
'LeetCode > NeetCode' 카테고리의 다른 글
[DP: Unbounded Knapsack] 983. Minimum Cost For Tickets (0) | 2025.01.13 |
---|---|
[DP: Unbounded Knapsack] 518. Coin Change II ★★★★★ (0) | 2025.01.08 |
[DP: LCS 최장공통수열 Longest Common Subsequence] 97. Interleaving String (1) | 2024.12.30 |
DP2D: 63. Unique Paths II (0) | 2024.12.30 |
23. Merge k Sorted Lists ★★★: Sorting(merge sort (0) | 2024.12.28 |