前端刷题路-Day81:单词拆分(题号139)

581 阅读3分钟

这是我参与8月更文挑战的第15天,活动详情查看:8月更文挑战

单词拆分(题号139)

题目

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

  • 拆分时可以重复使用字典中的单词。
  • 你可以假设字典中没有重复的单词。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
     注意你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

链接

leetcode-cn.com/problems/wo…

解释

这题啊,这题是经典DP。

一开始真没想到DP,一开始比较直接,直接一层for循环,利用额外的一个变量来存储当前单词的内容,如果当前单词在字典里面,就重置单词,这样如果循环完成后当前单词不为空,那就返回false,如果干到最后变为空了,那就证明这个字符串可以由字典中的单词构成。

代码也很简单👇:

var wordBreak = function(s, wordDict) {
  const set = new Set(wordDict)
  let word = ''
  for (const char of s) {
    word += char
    if (set.has(word)) {
      word = ''
    }
  }
  return !word
};

结果一个测试用例给我干懵了,就是这个:

"aaaaaaa"
["aaaa","aaa"]

为什么在这个测试用例会崩?其实也很简单了,因为原字符串应该是由aaaaaaa构成的,但根据上面代码的逻辑,遇到aaa会截断一次,那么此时字符串还剩下aaaa,遇到aaa会再截断一次,最后就剩一个a了,然后GG。

问题就出现在遇到第四个字符的时候,其实应该可以继续走的,不应该直接截断,那么此时就想到了第二种方案——递归。

利用递归来走那些没尝试过的可能性,这样覆盖面就广了。

具体的放到答案里进行解释了,此处就不占用篇幅了。

用了递归之后再看看官方答案,其实DP也是可以解决的,并且好像更简单些。

DP的思路也很简单,用dp[i]来分割数组,如果dp[0~i]dp[i~n]都满足字典的条件,那就证明当前的dp[i]是合格的,然后再拆分i,用循环来一点点找到0~jj~i的组合是不是符合条件,如果符合条件,证明dp[i]符合条件。

讲真,还是比较好理解的,而且整体思路也比较符合DP的整体思路,首先是递归,递归之后利用记忆化进行优化,最后推导出DP。

自己的答案(递归)

递归的思路也比较简单,为了避免错过的问题,得考虑到所有的可能性。

所以需要从数组的所有位置开始查找,如果当前单词在字典里,判断剩余的部分是否都在字典里,如果在就返回true,如果全都不在就返回false

var wordBreak = function(s, wordDict) {
  const len = s.length
  const set = new Set(wordDict)
  function DFS(start) {
    if (start === len) return true
    for (let i = start + 1; i <= len; i++) {
      if (set.has(s.slice(start, i)) && DFS(i)) return true
    }
    return false
  }
  return DFS(0)
}

答案显然没有这么简单,在执行到第34个巨长的测试用例时GG了,原因很简单,里面有很多数据进行了重复计算,浪费了性能。

自己的答案(递归+记忆化)

要想优化递归,首先得弄明白重复数据出现在哪里,看看👆的代码会发现,i会有大量的重复情况出现,举个例子:

"aaaaaaa"
["aaaa","aaa"]

一开始i肯定尝试了0~6,遇到2的时候start变成了3开始走,在这里会直接成功,但如果失败了呢?会走到下一次for循环中去,那么会再一次尝试到重复的失败数字,所以这里应该用一个东西来记录当前尝试过的结果,不管是失败还是成功,避免后续的重复计算。

var wordBreak = function(s, wordDict) {
  const len = s.length
  const set = new Set(wordDict)
  const map = new Map()
  function DFS(start) {
    if (start === len) return true
    if (map.has(start)) return map.get(start)
    for (let i = start + 1; i <= len; i++) {
      if (set.has(s.slice(start, i)) && DFS(i)) {
        map.set(i, true)
        return true
      }
    }
    map.set(start, false)
    return false
  }
  return DFS(0)
}

简简单单的用Map来记录即可,实现记忆化的操作,如此便可以走通所有的测试用例了。

更好的方法(动态规划)

DP的思路在解释中已经说过了,直接看代码:

var wordBreak = function(s, wordDict) {
  const len = s.length
  const set = new Set(wordDict)
  const dp = new Array(len + 1).fill(false)
  dp[0] = true
  for (let i = 0; i <= len; i++) {
    for (let j = 0; j < i; j++) {
      if (dp[j] && set.has(s.substr(j, i - j))) {
        dp[i] = true
        break
      }      
    }    
  }
  return dp[len]
};

主要就是将可能性拆解到dp数组中的每个元素,之后再将i拆解为j来进行分段处理,理解起来还是比较简单的。



PS:想查看往期文章和题目可以点击下面的链接:

这里是按照日期分类的👇

前端刷题路-目录(日期分类)

经过有些朋友的提醒,感觉也应该按照题型分类
这里是按照题型分类的👇

前端刷题路-目录(题型分类)