Want Help Cracking FAANG?

(Then click this)

×
Back to Question Bank

1397. Find All Good Strings - Leetcode Solution

Problem Description

The "Find All Good Strings" problem asks you to count the number of strings of a given length n that are lexicographically between two given strings s1 and s2 (inclusive), and do not contain a given evil substring. All strings are made up of lowercase English letters.

  • The strings s1 and s2 are both of length n.
  • You must count all strings x such that s1 ≤ x ≤ s2 and evil is not a substring of x.
  • Return the answer modulo 10^9 + 7.
  • Constraints: 1 ≤ n ≤ 500, 1 ≤ evil.length ≤ 50, and all strings consist of lowercase letters.

Thought Process

At first glance, the problem seems to ask for generating all possible strings of length n between s1 and s2, filtering out those that contain evil, and counting the rest. However, with n up to 500, brute-forcing all possible strings is computationally infeasible because the number of strings grows exponentially (26^n).

Instead, we need to count the valid strings efficiently, without generating them. This leads us to dynamic programming (DP), where we can recursively count possibilities while keeping track of:

  • How much of the evil substring we've seen so far (to avoid forming it).
  • Whether we're still "tight" to the lower bound (s1) or upper bound (s2).
  • The current position in the string.

To efficiently check for the presence of evil, we use ideas from the Knuth-Morris-Pratt (KMP) algorithm, which allows us to track prefix matches in evil as we build the string.

Solution Approach

The solution uses a digit DP (dynamic programming over digits/characters) with memoization, and a KMP-style automaton to avoid forming the evil substring.

  1. Preprocess the Evil String:
    • Build the KMP "failure function" for evil to allow fast state transitions when matching prefixes.
  2. Define the DP State:
    • pos: Current position in the string we're building.
    • matched: How many characters of evil we've matched so far.
    • is_tight_low: Are we still matching the lower bound (s1)?
    • is_tight_high: Are we still matching the upper bound (s2)?
  3. Recursive DP Function:
    • At each position, for each possible character (from low to high depending on tightness), try extending the string.
    • Update matched using the KMP automaton.
    • If matched equals evil.length, stop (invalid string).
    • If at the end (pos == n), count 1 valid string.
    • Memoize results to avoid recomputation.
  4. Combine Results:
    • Count all valid strings from s1 to s2 inclusive.
  5. Modulo Operation:
    • Return the answer modulo 10^9 + 7 as required.

Example Walkthrough

Let's say n = 2, s1 = "aa", s2 = "da", and evil = "b".

  1. Possible strings between "aa" and "da":
    • "aa", "ab", ..., "az"
    • "ba", "bb", ..., "bz"
    • "ca", ..., "cz"
    • "da"
  2. Remove any string containing "b":
    • Any string with a 'b' at any position is invalid.
  3. Count the rest:
    • The DP will efficiently count these by exploring only valid branches, skipping any that would create "b" as a substring.
  4. Tracking tightness:
    • At position 0, we must start from 'a' (since s1[0]='a'), and can't go above 'd' (since s2[0]='d').
    • At each step, tightness for lower/upper bound might be relaxed if we go above the bound at that position.
  5. Memoization:
    • DP caches results for each state to avoid recomputation.

Time and Space Complexity

  • Brute-force approach:
    • Would be O(26^n), which is infeasible for n up to 500.
  • Optimized DP approach:
    • There are n positions, up to evil.length match states, and 2 tightness flags (for low/high) each.
    • Total states: n * evil.length * 2 * 2 = up to 500 * 50 * 2 * 2 = 100,000 states.
    • For each state, we try up to 26 characters, so time complexity is O(n * m * 2 * 2 * 26), where m = evil.length.
    • Space complexity is O(n * m * 2 * 2) for memoization.

Summary

The "Find All Good Strings" problem is a classic example of digit DP, where we efficiently count valid strings within bounds, avoiding forbidden substrings by leveraging KMP-style prefix matching. The key insight is to model the problem as a recursive state machine, tracking how much of the forbidden substring we've seen, and whether we're still tight to the lower or upper bounds. This approach avoids brute-force enumeration and makes the solution efficient and elegant, even for large inputs.

Code Implementation

MOD = 10 ** 9 + 7

class Solution:
    def findGoodStrings(self, n, s1, s2, evil):
        m = len(evil)
        # Build KMP failure function
        fail = [0] * m
        for i in range(1, m):
            j = fail[i - 1]
            while j > 0 and evil[i] != evil[j]:
                j = fail[j - 1]
            if evil[i] == evil[j]:
                j += 1
            fail[i] = j

        from functools import lru_cache

        @lru_cache(maxsize=None)
        def dp(pos, matched, tight_low, tight_high):
            if matched == m:
                return 0
            if pos == n:
                return 1
            res = 0
            low = s1[pos] if tight_low else 'a'
            high = s2[pos] if tight_high else 'z'
            for c in range(ord(low), ord(high) + 1):
                ch = chr(c)
                k = matched
                while k > 0 and evil[k] != ch:
                    k = fail[k - 1]
                if evil[k] == ch:
                    k += 1
                res += dp(
                    pos + 1,
                    k,
                    tight_low and ch == low,
                    tight_high and ch == high
                )
                res %= MOD
            return res

        return dp(0, 0, True, True)
      
#include <vector>
#include <string>
#include <cstring>

using namespace std;
const int MOD = 1e9 + 7;

class Solution {
public:
    int n, m;
    string s1, s2, evil;
    vector<int> fail;
    int memo[501][51][2][2];

    void buildKMP() {
        fail.assign(m, 0);
        for (int i = 1; i < m; ++i) {
            int j = fail[i - 1];
            while (j > 0 && evil[i] != evil[j])
                j = fail[j - 1];
            if (evil[i] == evil[j]) ++j;
            fail[i] = j;
        }
    }

    int dp(int pos, int matched, bool tight_low, bool tight_high) {
        if (matched == m) return 0;
        if (pos == n) return 1;
        int &res = memo[pos][matched][tight_low][tight_high];
        if (res != -1) return res;
        res = 0;
        char low = tight_low ? s1[pos] : 'a';
        char high = tight_high ? s2[pos] : 'z';
        for (char ch = low; ch <= high; ++ch) {
            int k = matched;
            while (k > 0 && evil[k] != ch)
                k = fail[k - 1];
            if (evil[k] == ch) ++k;
            res = (res + dp(pos + 1, k, tight_low && ch == low, tight_high && ch == high)) % MOD;
        }
        return res;
    }

    int findGoodStrings(int n_, string s1_, string s2_, string evil_) {
        n = n_; s1 = s1_; s2 = s2_; evil = evil_;
        m = evil.length();
        buildKMP();
        memset(memo, -1, sizeof(memo));
        return dp(0, 0, 1, 1);
    }
};
      
class Solution {
    static final int MOD = 1000000007;
    int n, m;
    String s1, s2, evil;
    int[] fail;
    Integer[][][][] memo;

    void buildKMP() {
        fail = new int[m];
        for (int i = 1; i < m; ++i) {
            int j = fail[i - 1];
            while (j > 0 && evil.charAt(i) != evil.charAt(j))
                j = fail[j - 1];
            if (evil.charAt(i) == evil.charAt(j)) ++j;
            fail[i] = j;
        }
    }

    int dp(int pos, int matched, boolean tightLow, boolean tightHigh) {
        if (matched == m) return 0;
        if (pos == n) return 1;
        if (memo[pos][matched][tightLow ? 1 : 0][tightHigh ? 1 : 0] != null)
            return memo[pos][matched][tightLow ? 1 : 0][tightHigh ? 1 : 0];
        int res = 0;
        char low = tightLow ? s1.charAt(pos) : 'a';
        char high = tightHigh ? s2.charAt(pos) : 'z';
        for (char ch = low; ch <= high; ++ch) {
            int k = matched;
            while (k > 0 && evil.charAt(k) != ch)
                k = fail[k - 1];
            if (evil.charAt(k) == ch) ++k;
            res = (res + dp(pos + 1, k, tightLow && ch == low, tightHigh && ch == high)) % MOD;
        }
        return memo[pos][matched][tightLow ? 1 : 0][tightHigh ? 1 : 0] = res;
    }

    public int findGoodStrings(int n_, String s1_, String s2_, String evil_) {
        n = n_; s1 = s1_; s2 = s2_; evil = evil_;
        m = evil.length();
        buildKMP();
        memo = new Integer[n + 1][m + 1][2][2];
        return dp(0, 0, true, true);
    }
}
      
const MOD = 1e9 + 7;

var findGoodStrings = function(n, s1, s2, evil) {
    const m = evil.length;
    // Build KMP failure function
    const fail = new Array(m).fill(0);
    for (let i = 1; i < m; ++i) {
        let j = fail[i - 1];
        while (j > 0 && evil[i] !== evil[j]) {
            j = fail[j - 1];
        }
        if (evil[i] === evil[j]) ++j;
        fail[i] = j;
    }

    const memo = new Map();

    function dp(pos, matched, tightLow, tightHigh) {
        if (matched === m) return 0;
        if (pos === n) return 1;
        const key = [pos, matched, tightLow, tightHigh].join(',');
        if (memo.has(key)) return memo.get(key);
        let res = 0;
        let low = tightLow ? s1[pos] : 'a';
        let high = tightHigh ? s2[pos] : 'z';
        for (let c = low.charCodeAt(0); c <= high.charCodeAt(0); ++c) {
            let ch = String.fromCharCode(c);
            let k = matched;
            while (k > 0 && evil[k] !== ch) {
                k = fail[k - 1];
            }
            if (evil[k] === ch) ++k;
            res = (res + dp(pos + 1, k, tightLow && ch === low, tightHigh && ch === high)) % MOD;
        }
        memo.set(key, res);
        return res;
    }

    return dp(0, 0, true, true);
};