ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

139. Word Break

2021-01-07 18:02:04  阅读:159  来源: 互联网

标签:子串 Word len Break start 139 我们 dp 字典


今天又是刷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, vector<int>& memo){
        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)。

标签:子串,Word,len,Break,start,139,我们,dp,字典
来源: https://www.cnblogs.com/njuxjx/p/14247806.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有