Distinct Subsequences

Problem

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

  • 0 ≤ s.length, t.length ≤ 1000
  • s and t consist of English letters.

Example

Input: s = "rabbbit", t = "rabbit"
Output: 3

The string "rabbbit" has 3 distinct subsequences that equal "rabbit". The algorithm explores the string s starting from index 0 and t from index 0. At each step, if characters match, it considers two paths: matching these characters and moving both indices forward, or skipping the current character in s and moving only s's index forward. If characters don't match, it skips the current character in s. Memoization caches results for each (i, j) pair to avoid redundant computations. The critical insight is that the problem breaks down into counting subsequences by exploring these two choices recursively, and memoization ensures polynomial time complexity.

Approach

Straightforward Solution

A brute-force recursive approach tries all subsequences of s and checks if they equal t, resulting in exponential time due to repeated exploration of the same states.

Core Observation

The count of subsequences matching t from position j in s starting at position i depends on whether s[i] matches t[j]. If they match, the total count is the sum of counts from advancing both indices and counts from skipping s[i]. If they don't match, only skipping s[i] is valid. This recursive structure exhibits overlapping subproblems and optimal substructure.

Path to Optimal

Recognizing the overlapping subproblems, memoization is introduced to cache results for each (i, j) state, preventing redundant computations. This transforms the exponential recursion into a polynomial time dynamic programming solution. The problem is thus modeled as a top-down DP with memoization over indices of s and t.

Optimal Approach

Implement a recursive function dp(i, j) that returns the number of subsequences of s[i:] matching t[j:]. Use memoization to cache results. If characters match, sum the counts of matching both characters and skipping s[i]. If not, skip s[i]. Base cases handle when t is fully matched (return 1) or s is exhausted without matching t (return 0). This approach guarantees O(n*m) time and space complexity, where n and m are lengths of s and t respectively.

Time

O(n * m)

Each state (i, j) is computed once and cached. There are at most n * m states, where n = len(s) and m = len(t). Each state computation takes O(1) time.

Space

O(n * m)

The memoization dictionary stores results for each pair of indices (i, j), requiring O(n * m) auxiliary space. The recursion stack depth is at most O(n + m), which is bounded by O(n * m).

Pattern Spotlight

Dynamic Programming (Top-Down Memoization for Counting Subsequences)

When counting subsequences or combinations with overlapping subproblems, model the problem as a state machine over indices and cache results to avoid exponential recomputation, transforming recursion into efficient DP.

Solution

1class Solution:
2 def numDistinct(self, s: str, t: str) -> int:
3 memo = {}
4
5 def dp(i, j):
6 if j == len(t):
7 return 1
8 if i == len(s):
9 return 0
10 if (i, j) in memo:
11 return memo[(i, j)]
12
13 if s[i] == t[j]:
14 memo[(i, j)] = dp(i + 1, j + 1) + dp(i + 1, j)
15 else:
16 memo[(i, j)] = dp(i + 1, j)
17
18 return memo[(i, j)]
19
20 return dp(0, 0)

Step-by-Step Solution

1

Cache Recursive Results to Avoid Redundant Computations

3memo = {}
10 if (i, j) in memo:
11 return memo[(i, j)]

Objective

To store the results of subproblems defined by indices (i, j) to prevent exponential recomputation.

Key Insight

Memoization transforms the naive recursive solution into an efficient dynamic programming approach by caching the number of subsequences for each pair of indices. This avoids recomputing the same subproblems multiple times, which is the main cause of exponential time complexity in naive recursion.

Interview Quick-Check

Core Logic

Memoization caches results keyed by (i, j), ensuring each subproblem is solved once.

Common Pitfalls & Bugs

Forgetting to cache results leads to exponential time due to repeated exploration of identical states.

2

Return 1 When Target Fully Matched and 0 When Source Exhausted

6if j == len(t):
7 return 1
8if i == len(s):
9 return 0

Objective

To define base cases that terminate recursion when the target string is fully matched or the source string is exhausted without a match.

Key Insight

When the target index reaches its length, it means a valid subsequence has been found, so return 1. Conversely, if the source index reaches its length before the target is matched, no subsequence can be formed, so return 0. These base cases anchor the recursion and ensure correctness.

Interview Quick-Check

State & Boundaries

Return 1 when j == len(t) because the entire target has been matched.

State & Boundaries

Return 0 when i == len(s) but j < len(t) because no further matches are possible.

3

Sum Counts of Matching and Skipping Characters to Count Subsequences

13if s[i] == t[j]:
14 memo[(i, j)] = dp(i + 1, j + 1) + dp(i + 1, j)
15else:
16 memo[(i, j)] = dp(i + 1, j)

Objective

To compute the number of subsequences by considering both matching the current characters and skipping the current character in s.

Key Insight

If s[i] matches t[j], the total count includes subsequences that use this match (advancing both indices) and subsequences that skip s[i] (advancing only s's index). If characters don't match, only skipping s[i] is valid. This dual-path recursion captures all distinct subsequences without double counting.

Interview Quick-Check

Core Logic

When characters match, add counts from matching both and skipping s[i]; otherwise, only skip s[i].

Common Pitfalls & Bugs

Failing to consider both paths when characters match leads to undercounting subsequences.

4

Return Cached Result to Complete Recursive Computation

18return memo[(i, j)]

Objective

To return the computed or cached number of subsequences for the current indices.

Key Insight

Returning the memoized result ensures the recursion correctly propagates the count of subsequences up the call stack. This line finalizes the DP step by providing the answer for the current state.

Interview Quick-Check

Core Logic

Return the cached result for (i, j) to avoid recomputation and propagate the count.

5

Initiate Recursive Counting from Start of Both Strings

20return dp(0, 0)

Objective

To start the recursive counting process from the beginning of s and t.

Key Insight

Calling dp(0, 0) begins the exploration of all subsequences of s that could match t, leveraging the memoized recursive structure to efficiently count all valid subsequences.

Interview Quick-Check

Core Logic

Starting at indices 0, 0 ensures the entire strings are considered from the beginning.

Line Analysis

This solution has 8 Critical lines interviewers watch for.

Line 6 Critical
if j == len(t):

Check if the target string has been fully matched.

When j equals the length of t, it means a valid subsequence has been found, so return 1 to count this successful path.

Line 8 Critical
if i == len(s):

Check if the source string has been exhausted before matching the target.

If i equals the length of s but j is not at the end of t, no further subsequences can match, so return 0 to indicate failure.

Line 14 Critical
memo[(i, j)] = dp(i + 1, j + 1) + dp(i + 1, j)

Calculate and cache the sum of counts from matching and skipping the current character.

This line embodies the core DP recurrence: when characters match, the total count is the sum of subsequences that include this match and those that skip it, ensuring all distinct subsequences are counted.

Line 3 Critical
memo = {}

Initialize a memoization dictionary to cache results of subproblems.

Memoization is essential to avoid exponential recomputation by storing results for each (i, j) state, enabling efficient top-down dynamic programming.

Line 7 Critical
return 1

Return 1 to count a successful subsequence match.

Returning 1 here signals that one valid subsequence has been found, serving as a base case for the recursion.

Line 9 Critical
return 0

Return 0 to indicate no valid subsequence can be formed.

This base case prevents invalid paths from contributing to the count, ensuring correctness.

Line 10 Critical
if (i, j) in memo:

Check if the current subproblem result is already cached.

This lookup prevents redundant computation by returning the cached result if available, a key optimization for efficiency.

Line 11 Critical
return memo[(i, j)]

Return the cached result for the current indices.

Returning the memoized value ensures the recursion uses previously computed results, avoiding exponential time complexity.

Line 13 Core
if s[i] == t[j]:

Check if the current characters in s and t match.

Matching characters enable two recursive paths: using the match or skipping the character in s, which together count all valid subsequences.

Line 15 Core
else:

Handle the case where characters do not match.

If characters differ, only skipping the current character in s is valid, so the count is derived solely from dp(i + 1, j).

Line 16 Core
memo[(i, j)] = dp(i + 1, j)

Cache the count of subsequences when characters do not match.

Caching this result prevents redundant computation for this state, maintaining efficiency.

Line 18 Core
return memo[(i, j)]

Return the cached count of subsequences for the current state.

Returning the memoized result completes the recursive step, propagating the count up the call stack.

Line 20 Standard
return dp(0, 0)

Start the recursive counting from the beginning of both strings.

This call initiates the entire DP process, exploring all subsequences of s that could match t.

Test Your Understanding

Why does the algorithm add the results of dp(i + 1, j + 1) and dp(i + 1, j) when s[i] == t[j]?

Because when characters match, the total count includes subsequences that use this matching pair (dp(i + 1, j + 1)) and subsequences that skip the current character in s (dp(i + 1, j)), ensuring all distinct subsequences are counted without omission or duplication.

Related Problems

Dynamic Programming pattern

Don't just read it. Drill it.

Reconstruct Distinct Subsequences from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the Distinct Subsequences drill

or drill a free problem