139. Word Break

今天又是刷leetcode的一天。

今天做的是139. Word Break,说实话,我知道这道题是用dp来做,dp的两大关键就是定义子问题和写出状态转移方程。
其实一般来说,找到了子问题往往也就能用递归来做,只不过递归过程中有很多重复计算,所以为了避免这种重复计算,很多时候我们都是使用一种记忆性递归的做法。

典型的问题比如求斐波拉契数列,F(n) = F(n-1) + F(n-2), n≥2,当F(0)=F(1)=2.

这个计算过程是存在很多重复计算的,例如计算F(4) = F(3) + F(2),而计算F(3) = F(2) + F(1),可以看到这里F(2)计算了两次。

避免这种重复计算的办法也很简单,就是用一个数组把计算过的结果存下来,对于这个问题,我们可以在一开始递归之前新建一个长度为n+1的数组(因为F(0)到F(n)一共n+1项),每一项初始化为-1。
然后在求某一项的时候如果它没有被计算过,就计算它,并把它的值记录在这个数组中,如果计算过,那就直接从数组读取这个值并直接返回,避免进一步重复计算。

那么我们发现,即便不用dp去做,而用这种记忆型递归方法去做的话,首要一步还是确定原问题的子问题,因为只有确定了子问题,我们才能知道递归结构怎么写。

对于今天这道题,是要把一个字符串分成若干段,使得分出来的每一段都能在给定的字典中找到,换言之,就是说我们能不能从字典中选几个单词(每一个单词都可以重复选择),然后用选出来的这些单词排列一下就能得到给定的字符串。

例如用"leet" "code"两个单词是可以排列出"leetcode"的,题目的意思应该是比较清晰简单的。

那么用递归去做,我们先不考虑记忆性递归,该如何做呢?

我们先举一个例子

Input: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]

这里我们s串就是待分解的串,wordDict是我们的字典。

如果让你去分解你是怎么分解的?

我们从s串第一个字母c开始,看看字典中没有没它,我们发现没有,于是我们再往后看一个字母a,此时我们要判断字典中有没有"ca",我们不断这样往后看,

我们发现,当我们看到"cat"的时候,字典中存在这个字符串,于是我们再往后去试探,当我们试探到"sand"的时候,我们发现这个也能在字典中找到,我们接着往下找,

我们发现,剩下的"og"是不能在字典中找到的,问题是,这时候我们该怎么办?

没错,我们得在上一次成功的地方继续往后试探,因为这次试探不通,说明上次不应该停在"sand"那个位置,于是我们我们读入"sand"后面的o,发现"sando"找不到,一直到最后的"sandog"也找不到,
说明这一轮也失败了,于是继续回溯到上次成功的位置,那就是"cat",说明我们不应该停在这里,应该继续往后读入。。。

有没有发现这个过程貌似挺复杂的,对于不同长度的输入串s,我们压根就不知道应该回溯多少次,如果你只想到这里,你大概率是还没有发现原问题的子问题是啥,我觉得无论是递归还是dp,最重要的就是子问题。

子问题就是说和原问题的性质其实是一样的,但是问题规模却变小了一点。

例如,对于字符串问题,我们是经常使用dp的,为什么?因为字符串有子串这么一个概念,往往在子串中求解原问题对应的就是原问题的子问题。

比方说原问题是对s进行某种操作,如果是一维dp的话(也就是说我们只需要新建一个一维数组来保存状态值),那么子问题可以是对s串从0到i之间的子串进行这种操作,或者是对s串从i到最后一个位置之间的子串进行这种操作;如果是二维dp的话,一般是对s串从第i个位置到第j个位置之间的字符串进行这种操作。

那么回到这里,我们这个问题的子问题是什么呢?

从我们刚才的试探过程可以看出,每次我们在某个位置匹配上了,例如一开始我们试探到s中的"cat"时,我们接下来是继续往右试探下一个可能的切割位置,如果你停在这里想一下接下来的过程和原始问题的关系,你会发现这其实就是子问题了,原始问题是s串"catsandog"能不能拆分成字典中的词,现在是s的子串"sandog"能不能拆分成字典中的词,注意,因为字典中的词是可以重复利用的,所以无论我们递归到哪个子问题时,字典始终都是同一个字典,如果字典中的词不能重复使用,那么问题就不能这么简单考虑了。

anyway,到现在为止我们找到了原始问题的子问题,那么这个子问题如何定义呢?

还记得我刚才说过的吗,子问题可以定义成原始串从第i个位置开始到最后一个位置为止之间的子串满足某个条件。那么这里我们就将子问题定义为:

s串中从第i个位置到最后一个位置之间的子串是否能够拆分成字典中的词。

接下来我们来看看代码应该如何写

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        return check(s, 0, wordSet);
    }
    
    bool check(string s, int start, unordered_set<string>& wordSet){
        if(start == s.size()) return true;
        for(int i = start; i < s.size(); i++){
            if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet)) {
                return true;
            }
        }
        return false;
    }
};

首先题目给我们的时一个vector,在vector中查找一个词是否存在需要花费O(n)的时间,由于整个过程我们需要进行很多次查找,所以为了加快每一次的查找速度,我们把这些词保存到一个unordered_set中。

写递归函数第一步不是确定递归结构,而是确定边界条件,或者说递归到什么时候结束,这个就对应于dp问题中的状态初始值。

显然,这里是当i等于s.size()的时候结束,因为i==s.size()的时候说明子串的长度为0,一个空串当然可以用字典中的词来表示了,这相当于从字典中选了0个词来表示。

问题来了,接下来咋办?也就是如何设计递归结构?

Input: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]

首先,我们还是根据上面的思路来思考,假设现在子串起始位置start = 0,然后我们从起始位置start开始,一直往后试探,设i为当前的试探位置

首先判断start和i之间的子串(包括第start个位置和第i个位置)能不能在字典中找到,这个子串是什么呢?就是s.substr(start, i - start + 1),这个意思是说s中从下标start开始,长度为i - start + 1的子串。

比如当start = 0,i = 0的时候,这个子串就是"c"。

如果这个子串能在字典中找到,那么接下来的起始位置应该设为i+1,接下来就应该把start设为i+1去递归。
如果这个子串不能在字典中找到,那么接下来应该让i+1,去看看此时start和i之间的子串(包括第start个位置和第i个位置)能不能在字典中找到。

例如,我们上面的"c"是不能在字典中找到,于是i+1,这个时候的子串是"ca",也不行。

一直到start=0, i=2的时候可以,于是这个时候把start更新为3,去判断"sandog"能不能被字典中的单词表示,最后递归下去发现不行。

那么什么时候可以知道以start开头的子串不能被字典中的单词表示呢?

就是整个for循环走遍了还发现无法找到一个合适的位置,所以for循环最后有一个return false;

以上是普通的递归过程,那么记忆型递归起始很简单,就是我们要判断一下以i开头的子串能不能被字典表示。

我们发现这里有两个位置是确定了一个子串能不能被字典表示,一个是

if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet)) {
      return true;
}

另外一个是:

return false;

于是我们在整个递归之前建立一个数组memo,长度为len == s.size(),其中memo[i]表示s[i->len-1]能不能被字典中的单词表示。
一开始memo中的每个值都设为-1,一旦在上面两个位置确定了memo[i]的值,如果为true,就把memo[i]设为1,如果为false,memo[i]设为0。

另外,我们需要在进行该轮计算之前先判断一下这个值是不是计算过,于是记忆型递归完整代码如下:

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<int> memo(s.size(), -1);
        return check(s, 0, wordSet, memo);
    }
    
    bool check(string s, int start, unordered_set<string>& wordSet, vector<int>& memo){
        //判断s从下标start后面开始到s最后一个字符的子串是否能分解成wordSet中的单词
        if(start == s.size()) return true;
        if(memo[start] != -1) return memo[start];
        for(int i = start; i < s.size(); i++){
            if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet, memo)) {
                return memo[start] = 1;
            }
        }
        
        return memo[start] = 0;
    }
};

ok,其实一般用dp能过的题,用记忆型递归也能过。上面的记忆型递归代码是能AC的,不过我们还是再想想用dp怎么做?
同样,我们也思考一下子问题如何定义,这里我们不妨直接取上面一样的定义,我们新建一个数组dp,长度为s.size()+1。其中dp[i]表示s[i->len-1](其中len表示s.size())这个子串 能不能被字典中的单词表示。

显然我们计算顺序应该是先计算dp[len-1],再计算dp[len-2]。。。一直到最后的dp[0]。dp[0]就是原始问题的解,我们return dp[0]就可以了。

dp问题还有一个初始状态,初始状态指的是我们一开始就能确定的状态,在这里是dp[len],我们初始化为true,dp数组其他元素都初始化为false。

可能有同学觉得为什么初始状态不是dp[len-1],因为dp[len-1]事实上也需要计算的,当然了,如果你一开始就去单独计算出dp[len-1]的值那你也可以把它当作初始状态,这样的话dp数组就定义为len的长度就行了。

接下来我们思考dp中的状态转移方程是什么?

首先当i=len-1的时候,我们知道dp[len-1]是不是为true,取决于s[len-1]在字典中能不能找到。

可是当i是中间一个一般情况下的位置时如何判断?

例如:
i len-1
x x x x x .... x x .... x

对于这种一般情况,我们怎么确定dp[i]是true还是false。

我们还是延用我们刚才递归解法的思路,就是从第i个位置往后出发,假设试探到了第j个位置,我们先看一下s[i->j]这个子串(闭区间)能不能在字典中找到,能的话再看看dp[j+1]是不是为true。
如果不是的话,j继续往后试探,如果试探到最后面一个位置也没有找到适合的j,那么说明dp[i]为false。

所以整个dp代码如下所示:

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        int len = s.size();
        vector<bool> dp(len+1, false); dp[len] = true;
        //dp[i]定义为s[i->len-1]是否可以被字典中的单词表示
        for(int i = len-1; i >= 0; i--){
            for(int j = i; j < len; j++){
                if(wordSet.count(s.substr(i, j-i+1)) && dp[j+1]){
                    dp[i] = true;
                    break;
                }else {
                    if (j == len - 1) dp[i] = false;
                }
            }
        }
        
        return dp[0];
    }
};

其实两份代码的运行时间基本一样,可以看到,虽然是一维dp,但是复杂度却是O(n^2)。

posted @ 2021-01-07 17:55  nullxjx  阅读(118)  评论(0编辑  收藏  举报