leetcode 1397 找到所有好字符串

367 阅读6分钟

这题目挺难的,前前后后断断续续看了有两三天,中间有几天没看。主要是dfs 是顺序遍历、逆序生成结果,这个特点有点反常规思维、很不好理解。除非理解了dfs的解法,不然直接看dp解法是无法理解的,因为dp数组怎么解释各个维度的含义是个很大问题,说不清。这题目难不是难在数位dp,是难在dfs生成结果的顺序和遍历的顺序是相反的。这样生成最终的初始结果之前遍历的时候很多概念不知道怎么解释, 除非理解了dfs生成结果的时机。

dfs解法, 其实也可以说是dp。带备忘录的dfs或者暴力循环就是dp啦。当然,bfs和dfs都是一种特殊的有步骤、有条理的、稍微比较复杂的暴力循环。

/**
 * @param {number} n
 * @param {string} s1
 * @param {string} s2
 * @param {string} evil
 * @return {number}
 * 单个字母的字典序就是排在字母表前面的字母小,排在字母表后面的字母大。
 * 字符串的字典序是从第一个字符开始往后一一对比,如果两个字符相等就比较打下一个;
 * 直到两个字符不等或其中一个字符串没有更多字母,这时长度大的字符串大。
 */

const mod = 10 ** 9 + 7

let cache = null

var findGoodStrings = function(n, s1, s2, evil) {
    /**
     * 9个bit,生成的字符串s最长为500个字符, 需要 2 ** 9 > 500
     * 6个bit,evil串最长长度为50, 需要 2 ** 6 > 50
     * leftBound 1个bit
     * rightBound 1个bit
     * 总共17个bit,记录生成的s串长度为mainTraver、匹配evil的长度为evilMatch、s中最后一个字符
     * 与下、上界是否接壤的的每种情况下生成的合法字符串的数目
     * 深度优先遍历的特点是,遍历是从第1层开始的,生成结果却是从最后一层开始倒着生成的
     * 所以cache记录的是所有已生成的完整的s串中, 前缀长度为mainTraver、evil匹配长度为evilMatch、
     * 以s的前缀长度mainTraver、evil已匹配的长度evilMatch、s的前缀最后一个字符接壤s1、s2的下标为mainTravel - 1的字符做上下界的情况
     * 四个维度做category分类统计的s合法串的数目
     * 所以最后的答案就是s串前缀长度mainTravel为0、evil已匹配长度为0、s串前缀最有后一个字符
     * 上下界都接壤的类目下合法s串的数目
     */
    cache = new Array(1 << 17).fill(-1)
    return dfs(0, 0, n, s1, s2, evil, true, true, computeNext(evil))
};
/**
 * 深度优先遍历:
 * 解法是从s1和s2的第1个字符开始从左到右遍历到最后一个字符
 * 每遍历一个位置i(i的范围是[0, n], 0表示还没开始遍历,是初始条件,n表示遍历完了), 
 * 当前位置的字符可以从from变化到to,from由
 * i - 1选择的字符是否等于s1[i - 1]和s1[i]决定,同理to由i - 1选择
 * 的字符是否等于s2[i -1]和s2[i]决定。
 * i位置每选择一个字符就要确定遍历到i位置为止生成的字符串中evil能匹配的长度。如果evilMatch
 * 灯evil.length,说明当前生成的字符串和以这个字符串为前缀的字符串都是非法的,直接返回0
 * 如果s1已经遍历到第n个字符,那么说明生成了一个完整的长度为n的合法的字符,返回1.
 * 初始条件是s1中遍历到第0个字符、evil匹配长度是0、上一个选择的字符既挨着上界又挨着下界(因为true && 任意值的话真假值由后面条件决定)
 * mainTravel 上一次dfs已生成的s串的长度,初始传入0,表示初始条件是一个字符都没有生成
 * evilMatch evil中与上一次已生成的s串匹配的长度,初始值同样是0
 * n
 * s1
 * s2
 * evil, 这四个参数不解释,不是猪就不用说
 * leftBound 上一个位置选择的字符是否挨着下界, 1表示挨下界 0不挨下界
 * rightBound 上一个位置选择的字符是否挨着上界 1挨上界
 * next, 如果选择的当前字符与evil中当前待比较的那个字符不相等, 下一个我要用evil中哪个下标的字符
 * 来跟当前选择的字符做比较,实际上也就得到了选择某个字符延长生成的s串后evil与新生成的串匹配的长度
 */
function dfs(mainTravel, evilMatch, n, s1, s2, evil, leftBound, rightBound, next) {
    // 如果上一次dfs已生成的s串, evil全部能匹配到, 那么已这个s串为前缀的串都非法,
    // 直接返回0,这个分支也不用再继续遍历了下去了
    if(evilMatch === evil.length) return 0
    // 这表示生成了一个完整的s串并且是合法的,dfs遍历到最后生成初始结果
    if(mainTravel === n) return 1
    let key = generateKey(mainTravel, evilMatch, leftBound, rightBound)
    // 因为四个类目的任意组合只会出现一次,不会有重复,所以如果已经统计好了这个类目下的数据
    // 那就可以直接复用以前的结果
    if(cache[key] !== -1) return cache[key]
    let from = leftBound ?  s1.charCodeAt(mainTravel) : 97
    let to = rightBound ?  s2.charCodeAt(mainTravel) : 122
    let res = 0
    for(let i = from; i <= to; i++) {
        let c = String.fromCharCode(i)
        /**
        * 找到evil与当前生成的字符串能匹配的长度
        * 实际上这道题目里evilMatch只会变长或保持不变、不会变短
        */
        let j = evilMatch
        while((j > 0) && (evil[j] !== c)) j = next[j - 1]
        if(evil[j] === c) j++
        res += dfs(mainTravel + 1, j, n, s1, s2, evil, leftBound && (i === from), rightBound && (i === to), next)
        res %= mod
    }
    cache[key] = res
    return res
}

// 生成cache的key
function generateKey(mainTravel, evilMatch, leftBound, rightBound) {
    return (mainTravel << 8) | (evilMatch << 2) | ((leftBound ? 1 : 0 ) << 1) | (rightBound ? 1 : 0)
}

 /**
  * 到某一个下标为止, 和某个后缀相等的前缀的长度的最大值(自己等于自己不算,
  * 也就是长度最大值是下标,不是下标 + 1)。
  * 也就是如果evil当前下标的指示的字符与主串中当前字符不相等, evil要往右
  *  滑动多少个位置。也就是下一步evil用哪个下标指示的值去与主串中的当前位置的字符做比较
  */
function computeNext(evil) {
    let n = evil.length
    let arr = new Array(n).fill(0)
    arr[0] = 0
    let j = 0
    for(let i = 1; i < n; i++) {
        while((j > 0) && (evil[i] !== evil[j])) j = arr[j - 1]
        if(evil[i] === evil[j]) arr[i] = ++j
        
    }
    return arr
}