View on NeetCode
View on LeetCode

Problem

Given two strings s and t, return the number of distinct subsequences of s which equals t.

The test cases are generated so that the answer fits on a 32-bit signed integer.

Example 1:

Input: s = "rabbbit", t = "rabbit"
Output: 3
Explanation:
As shown below, there are 3 ways you can generate "rabbit" from s.
rabb**b**it
ra**b**bbit
rab**b**bit

Example 2:

Input: s = "babgbag", t = "bag"
Output: 5
Explanation:
As shown below, there are 5 ways you can generate "bag" from s.
**ba**b**g**bag
**ba**bgba**g**
**b**abgb**ag**
ba**b**g**bag**
babg**bag**

Constraints:

  • 1 <= s.length, t.length <= 1000
  • s and t consist of English letters.

Solution

Approach: 2D Dynamic Programming

The key insight is that for each character in s, we decide whether to use it to match a character in t or skip it.

Implementation

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        m, n = len(s), len(t)

        # dp[i][j] = number of ways to form t[0:j] from s[0:i]
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        # Base case: empty t can be formed in one way (delete all from s)
        for i in range(m + 1):
            dp[i][0] = 1

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                # Don't use s[i-1] to match
                dp[i][j] = dp[i-1][j]

                # Use s[i-1] to match t[j-1]
                if s[i-1] == t[j-1]:
                    dp[i][j] += dp[i-1][j-1]

        return dp[m][n]

Approach 2: Space-Optimized 1D DP

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        m, n = len(s), len(t)

        # Only need previous row
        prev = [0] * (n + 1)
        prev[0] = 1

        for i in range(1, m + 1):
            curr = [0] * (n + 1)
            curr[0] = 1

            for j in range(1, n + 1):
                # Don't use current character of s
                curr[j] = prev[j]

                # Use current character if it matches
                if s[i-1] == t[j-1]:
                    curr[j] += prev[j-1]

            prev = curr

        return prev[n]

Approach 3: Recursion with Memoization

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        memo = {}

        def dp(i, j):
            # Base cases
            if j == len(t):
                return 1  # Matched all of t
            if i == len(s):
                return 0  # No more characters in s

            if (i, j) in memo:
                return memo[(i, j)]

            # Don't use s[i]
            result = dp(i + 1, j)

            # Use s[i] if it matches t[j]
            if s[i] == t[j]:
                result += dp(i + 1, j + 1)

            memo[(i, j)] = result
            return result

        return dp(0, 0)

Complexity Analysis

2D DP:

  • Time Complexity: O(m * n), where m and n are lengths of s and t.
  • Space Complexity: O(m * n) for the dp table.

1D DP:

  • Time Complexity: O(m * n).
  • Space Complexity: O(n), only storing two rows.

Key Insights

  1. DP State: dp[i][j] = number of distinct subsequences of s[0:i] that equal t[0:j].

  2. Two Choices at Each Position:
    • Skip current character in s: dp[i-1][j]
    • Use current character if it matches: dp[i-1][j-1]
  3. Character Match: When s[i-1] == t[j-1], we add both choices.

  4. Base Cases:
    • Empty t can always be formed (dp[i][0] = 1)
    • Empty s cannot form non-empty t (dp[0][j] = 0 for j > 0)
  5. Count All Ways: Unlike LCS which finds length, we count the number of different ways to form subsequences.

  6. Order Matters: We process s left to right, maintaining order required for subsequences.