1397-找到所有好字符串
给你两个长度为 n
的字符串 s1
和 s2
,以及一个字符串 evil
。请你返回 **好字符串 **的数目。
好字符串 的定义为:它的长度为 n
,字典序大于等于 s1
,字典序小于等于 s2
,且不包含 evil
为子字符串。
由于答案可能很大,请你返回答案对 10^9 + 7 取余的结果。
示例 1:
**输入:** n = 2, s1 = "aa", s2 = "da", evil = "b"
**输出:** 51
**解释:** 总共有 25 个以 'a' 开头的好字符串:"aa","ac","ad",...,"az"。还有 25 个以 'c' 开头的好字符串:"ca","cc","cd",...,"cz"。最后,还有一个以 'd' 开头的好字符串:"da"。
示例 2:
**输入:** n = 8, s1 = "leetcode", s2 = "leetgoes", evil = "leet"
**输出:** 0
**解释:** 所有字典序大于等于 s1 且小于等于 s2 的字符串都以 evil 字符串 "leet" 开头。所以没有好字符串。
示例 3:
**输入:** n = 2, s1 = "gx", s2 = "gz", evil = "x"
**输出:** 2
提示:
s1.length == n
s2.length == n
s1 <= s2
1 <= n <= 500
1 <= evil.length <= 50
- 所有字符串都只包含小写英文字母。
说明
本题来源于一家印度公司的面试题,其难度远高于国内和北美的面试难度。如果你觉得此题无从下手、对下一节的「预备知识」一无所知、或者是看不懂这篇题解,都是很正常的。这道题已经达到了竞赛的难度,即使是非常优秀的竞赛选手,也要花至少 10 分钟的时间写出完整(但不一定可读)的代码。竞赛选手有大量的做题基础,因此在遇到这题时几乎不需要思考的时间,可以直接上手编写代码。而对于普通的面试准备者来说,在 40 分钟内理清思路、写出代码、编写测试并给面试官讲解清楚是几乎不可能的。
预备知识
数位动态规划(数位 DP)
数位动态规划是一类特殊的动态规划,它的形式一般为:
给定下界 l 和上界 r,求 [l, r] 之间满足某一要求的元素个数。
在力扣平台上,数位动态规划的题目很少,典型的例子为 1012. 至少有 1 位重复的数字 ,即给定下界 1 和上界 N,求上下界之间满足「至少有 1 位重复数字」的元素个数。
我们如何解决数位动态规划的题目呢?一般来说,数位动态规划有一个特定的状态表达:
f[\textit{pos}][\textit{stats}][\textit{bound}]
它表示:
我们现在处理到了数的第 pos 位。在数位动态规划中,我们是从高位到低位,一位一位地处理数字的。例如当下界 l = 13,上界 r = 678 时,我们会先将上下界的高位补零使得它们拥有相同的位数,即 (l, r) = (013, 678)。随后我们从高位开始进行动态规划;
在 pos 位之前的那些数的状态被压缩成了 stats。这是什么意思呢?我们举一个简单的例子,假设我们现在想要求出在 [l, r] 之间各位数字之和是 5 的倍数的所有数,那么此时 stats 就可以表示为「在 pos 位之前的那些数的数字之和对 5 取模的值」,这样我们只用一个整数就能够表示 pos 位之前的状态。如果在第 pos 位我们选择了数 d,那么动态规划中的下一个状态就为 f[\textit{pos} + 1][(\textit{stats} + d) \bmod 5][\ldots];
在 pos 位之前的那些数和上下界 l, r 的关系为 bound。这又是什么意思呢?我们还是使用上面的那个例子,求出 [013, 678] 内各位数字之和是 5 的倍数的所有数。
如果最高位的数字我们选择了 2,它和上下界的最高位数字没有任何关系,因此对于次高位的数字,我们可以在 [0, 9] 之间任意选择;
如果最高位的数字我们选择了 6,此时这个数字是「贴着」上界的,也就是说,对于次高位的数字,我们只能在 [0, 7] 之间选择,其中 7 就是上界的次高位的数字。如果我们选择了数字 8 或 9,那么整个数为 68_,无论最后一位怎么选择,都不可能在上下界的区间内;
如果最高位的数字我们选择了 0,此时这个数字是「贴着」下界的,也就是说,对于次高位的数字,我们只能在 [1, 9] 之间选择,其中 1 就是下界的次高位数字。如果我们选择了数字 0,那么整个数为 00_,无论最后一位怎么选择,都不可能在上下界的区间内;
此外,还有一种最为特殊的情况。如果上下界为 (l, r) = (123, 156),并且最高位我们选择了 1,那么此时这个数字既「贴着」上界,也「贴着」下界,对于次高位的数字,我们只能在 [2, 5] 之间选择,其中 2 是下界的次高位数字,5 是上界的次高位数字。
综上所述,bound 共有 4 种不同的情况,我们可以给它们分别设定取值 0, 1, 2, 3:
如果和上下界没有任何关系,那么取值为 0,并且以后也不可能和上下界有关系;
如果「贴着」下界,那么取值为 1,并且只有当第 pos 位取了下界对应位置的数字时,才会延续 1 值,否则会变为 0;
如果「贴着」上界,那么取值为 2,并且只有当第 pos 位取了上界对应位置的数字时,才会延续 2 值,否则会变为 0;
如果同时「贴着」上下界,那么取值为 3,并且:
如果 pos 位同时取了上下界对应位置的数字(此时上下界对应位置的数字一定相同),那么延续 3 值;
如果 pos 位取了下界对应位置的数字,那么会变为 1;
如果 pos 位取了上界对应位置的数字,那么会变为 2;
其余情况会变为 0。
另一种理解方式是,bound 是一个 2 位的二进制数,低位为 1 当且仅当「贴着」下界,高位为 1 当且仅当「贴着」上界。
而对于 f[\textit{pos}][\textit{stats}][\textit{bound}] 的值本身,它表示「满足上述条件的数的个数」。这样以来,我们可以使用记忆化搜索(DFS + memo)的方法进行动态规划,即
f[\textit{pos}][\textit{stats}][\textit{bound}] = \sum_d f[\textit{pos + 1}][g_s(\textit{stats}, d)][g_b(\textit{bound}, d)]
其中 d 为第 pos 位选择的数字;g_b(\textit{bound}, d) 是关于 bound 的转移函数,例如上文中四个取值 0, 1, 2, 3 之间的转化;g_s(\textit{stats}, d) 是关于 stats 的转移函数,例如上文中的 (\textit{stats} + d) \bmod 5。最终的答案存放在
f[0][\textit{stats}_{\textit{init} }][3]
中,即满足「枚举到最高位、初始状态、同时贴着上下界(可以想象成更高位还可以补零,那么在最高位之前的数字都是贴着上下界的)」的数的个数,也就是需要求出的答案。
KMP
KMP 是一种字符串匹配算法,在力扣平台上出现的次数不少,但一般会用较易理解的「Rabin-Karp 字符串哈希算法」替代之,例如 1392. 最长快乐前缀 。在本题中,KMP 算法用来计算关于 stats 的转移函数 g_s(\textit{stats}, d),但由于算法时间的瓶颈不在于此(也就是说,我们用最暴力的方法求出转移函数,都可以在规定的时间内通过本题),并且 KMP 本身的证明很复杂,因此这里不再赘述,有兴趣的读者可以尝试查阅相关资料(例如阅读算法导论的相关章节、使用搜索引擎等)进行学习。
方法一:数位动态规划
本题虽然给定的是两个字符串,但仍然可以用数位动态规划的方法解决,因为根据字符串的字典序,它就具有
给定下界 l 和上界 r,求 [l, r] 之间满足某一要求的元素个数。
的形式。其中「下界」为给定的字符串 s1,「上界」为给定的字符串 s2,「要求」为「字符串中不能出现子串 evil」。
在预备知识的章节中,我们已经详细介绍了 f[\textit{pos}][\textit{stats}][\textit{bound}] 中的 pos 和 bound,它们基本不会随着题目而改变。但 stats 和它的转移函数 g_s(\textit{stats}, d) 是和题目紧密相关的,在本题中,我们可以将 stats 定义为:
在 pos 位之前的那些字符组成的字符串,匹配到 evil 的位置;也就是说,stats 表示最大的 l,使得前者长度为 l 的后缀等于后者长度为 l 的前缀。这和 KMP 算法中对于「匹配」的定义也是一致的。
举一个例子,假设 evil 为 “abcab”,如果 pos 为之前的那些字符组成的字符串为 “adab”,那么就匹配到了 evil 中的第 1 个字符(最左侧为第 0 个字符),即第一次出现的 b。对于第 pos 位,如果我们选择字符 “c”,那么匹配的位置会增加 1,因为 “adabc” 匹配到了 “abcab” 中的第 2 个字符;如果我们选择字符 “a”,那么匹配的位置会变为 0,因为 “adaba” 只能匹配到 “abcab” 中的第 0 个字母;如果我们选择其它的字符(例如 “b”),那么情况就很糟了,”adabb” 不能匹配 “abcab” 中的任何位置。
这样以来,只有当 stats 指向了 evil 中的最后一位时,当前的字符串才会包含 evil。此时我们需要在记忆化搜索中进行回溯并返回 0,这是因为后面搜索到的所有字符串都一定包含 evil 了。除此之外,我们可以一直搜索下去,直到达到边界。
那么给定当前的状态 stats 和第 pos 位选择的字符 d,我们如何计算转移函数 g_s(\textit{stats}, d) 呢?此时就需要 KMP 算法的帮助了。在 KMP 算法中,我们需要两个字符串,其一为「模式串」,另一位「主串」,我们需要判断「主串」中是否包含「模式串」,具体的做法就是按照顺序遍历「主串」中的每一个字符,并根据上一次在「模式串」中匹配到的位置以及当前遍历到的字符,求出此字符之后在「模式串」中匹配到的位置。一旦我们匹配到了「模式串」的末尾,就说明「主串」中出现了「模式串」。而匹配时用到的转移函数就是 KMP 算法中对「模式串」求得的 fail}[] 数组。因此在本题中,我们也只需要对 evil 求出对应的 fail}[] 数组,就可以计算转移函数了。
到这里,题解的分析部分就结束了,你是不是还觉得一头雾水?没关系,如果你真的想解决本题,编者在这里推荐了一系列的步骤:
学习 KMP 算法。具体地,可以参考《算法导论》或使用搜索引擎查找介绍 KMP 算法的博客。KMP 算法理解起来十分不容易,对于本题而言,你需要掌握 KMP 算法中的 fail}[] 数组(也就是「模式串」对应的数组),并且知道是如何在「主串」上匹配「模式串」的;
如果实在无法理解 KMP 算法,也可以放弃上一步,从而尝试写一个暴力的转移函数 g_s(\textit{stats}, d)。具体地,你只要编写一个函数,求出 evil 的前 stats 个字符加上 d 组成的字符串与 evil 的匹配长度,你只需要暴力地枚举 l,再判断前者长度为 l 的后缀等于后者长度为 l 的前缀即可。这一步对于大部分的读者应该不会很难;
尝试理解上文关于数位 DP 的预备知识。同样地,有很多介绍数位 DP 的博客。为了检测你是否正确理解了数位 DP,你可以阅读本题给出的参考代码,其中包含大量的注释。如果你都能理解,说明你已经掌握了数位 DP 的预备知识;
自己编写本题的代码。在编写过程中坚持住不要回看参考代码,如果在编写过程中有地方卡住了,可以在纸上自行推导一下,这样有助于加深你对数位 DP 以及 KMP 算法的理解。
只要跟着这些步骤自主学习,你一定能够做出本题的!并且编者相信,如果做出本题,你一定能获得很大的成就感!
1 | class Solution { |
1 | class Solution { |
1 | class Solution: |
复杂度分析
时间复杂度:O(NMS),其中 N 是字符串 s1 和 s2 的长度,M 是字符串 eval 的长度,S 是字符集大小,在本题中字符串仅包含小写字母,即 S = 26。时间复杂度分为以下这些部分:
计算字符串 eval 的 fail}[] 数组需要的时间复杂度为 O(M);
在记忆化搜索中状态数量有 O(NM4) = O(NM) 个,每个状态需要枚举后续最多 S 个状态进行转移,时间复杂度为 O(NMS);
在进行转移时,需要借助 fail}[] 数组计算转移函数,这部分的均摊复杂度为单次 O(1),时间复杂度为 O(NMS)。
因此总时间复杂度为 O(NMS)。
空间复杂度:O(M(N + S))。空间复杂度分为以下这些部分:
fail}[] 数组需要的空间复杂度为 O(M);
存储转移函数结果的数组需要的空间复杂度为 O(MS);
存储记忆化搜索结果的数组需要的空间复杂度为 O(NM)。
因此总空间复杂度为 O(M(N + S))。