题目
Given a string s and a dictionary of strings wordDict, return true if s can be segmented into a space-separated sequence of one or more dictionary words.
Note that the same word in the dictionary may be reused multiple times in the segmentation.
Ex
Example 1:
Input: s = "leetcode", wordDict = ["leet","code"]
Output: true
Explanation: Return true because "leetcode" can be segmented as "leet code".
Example 2:
Input: s = "applepenapple", wordDict = ["apple","pen"]
Output: true
Explanation: Return true because "applepenapple" can be segmented as "apple pen apple".
Note that you are allowed to reuse a dictionary word.
Example 3:
Input: s = "catsandog", wordDict = ["cats","dog","sand","and","cat"]
Output: false
Constraints:
-
1 <= s.length <= 300
-
1 <= wordDict.length <= 1000
-
1 <= wordDict[i].length <= 20
-
s and wordDict[i] consist of only lowercase English letters.
-
All the strings of wordDict are unique.
思路
对于单词 s 的分割,是否都可以在 wordDict 中。
可以有两个入手的角度。
切割 s
第一种方式,我们可以把单词 s 不断的进行切割。
然后把所有的结果,找到一种全部的子字符串都在 wordDict 中。
wordDict 拼接
也可以反过来思考。
我们可以从 wordDict 中重复取出单词,去拼接对应的字符串,如果匹配,则说明完成,直接返回。
算法实现
V1-backtrack 回溯算法
我们这里使用 wordDict 拼接,采用回溯的算法。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
List<String> resultList = new ArrayList<>();
// 存放字符串的子集
List<String> tempList = new ArrayList<>();
// 回溯問題?
backtrack(s, wordDict, tempList, resultList);
// 全部使用到了
return resultList.size() > 0;
}
private void backtrack(String target, List<String> wordDict,
List<String> tempList,
List<String> resultList) {
// 终止条件
String tempString = buildString(tempList);
// 剪枝
if(tempString.length() > target.length()) {
return;
} else if(tempString.equals(target)) {
resultList.addAll(new ArrayList<>(tempList));
return;
} else {
// 剪枝
if(resultList.size() > 0) {
return;
}
// 拼接,可以从字典中任意取一个。可以重复使用
for(String word : wordDict) {
tempList.add(word);
// 回溯
backtrack(target, wordDict, tempList, resultList);
// 移除最后一个
tempList.remove(tempList.size()-1);
}
}
}
private String buildString(List<String> tempList) {
StringBuilder stringBuilder = new StringBuilder();
for(String temp : tempList) {
stringBuilder.append(temp);
}
return stringBuilder.toString();
}
}
不过,这个方法会在 32/45 的时候,直接超时。
V2.1 剪枝优化
发现少考虑了一个剪枝的算法。
那就是拼接的 tempString 其实必须是 target 目标字符串的开头。
// 剪枝
if(tempString.length() > target.length()
|| !target.startsWith(tempString)) {
return;
}
此时可以把测试用例到
s =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"
wordDict =
["a","aa","aaa","aaaa","aaaaa","aaaaaa","aaaaaaa","aaaaaaaa","aaaaaaaaa","aaaaaaaaaa"]
看到这个用例,最大的想法,感觉就是贪心。
我们如果先对 wordDict 进行排序,把长度最长的排在前面呢?
V2.2 调整 wordDict 的顺序
优先把 wordDict 中的单词顺序放在前面,或者加一个多的字符的去重。进行快速失败
package com.github.houbb.leetcode.F100T200;
import java.util.*;
/**
* @author binbin.hou
* @since 1.0.0
*/
public class T139_WordBreakV2 {
/**
*
* 1 <= wordDict.length <= 1000
* 1 <= wordDict[i].length <= 20
*
* 单词的长度可能比较长,单词词库的数量有限。
*
* 感觉可以反过来思考:
*
* 1. 从词典中选择一个词,构建字符串
* 2. 从词典中继续选择一个词(单词可以复用,不需要移除), 构建字符串剩余的部分
* 3. 使用 backtrack
*
* @param s 目标
* @param wordDict 单词库
* @return 结果
*/
public boolean wordBreak(String s, List<String> wordDict) {
List<String> resultList = new ArrayList<>();
//2. 第二个问题,如果很长的一个字符串,最后一个却不存在。
// 那么可否把 s 进行拆分去重?
// 但是感觉这样也是指标不治本
boolean fastFailed = fastFailed(s, wordDict);
if(!fastFailed) {
return false;
}
// 调整 wordDict 的顺序,把长度较长的放在前面。便于 greedy。
Collections.sort(wordDict, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.length() - o1.length();
}
});
// 存放字符串的子集
List<String> tempList = new ArrayList<>();
// 回溯問題?
backtrack(s, wordDict, tempList, resultList);
// 全部使用到了
return resultList.size() > 0;
}
private boolean fastFailed(String s, List<String> wordDict) {
Set<Character> targetSet = getCharSet(s);
Set<Character> wordSet = getCharSet(buildString(wordDict));
for(Character character : targetSet) {
if(!wordSet.contains(character)) {
return false;
}
}
return true;
}
private Set<Character> getCharSet(String text) {
char[] chars = text.toCharArray();
Set<Character> set = new HashSet<>();
for(char c : chars) {
set.add(c);
}
return set;
}
private void backtrack(String target, List<String> wordDict,
List<String> tempList,
List<String> resultList) {
// 终止条件
String tempString = buildString(tempList);
// 剪枝
if(tempString.length() > target.length()
|| !target.startsWith(tempString)) {
return;
} else if(tempString.equals(target)) {
// 后来发现,并不需要用完所有的单词。
// 这里不需要返回结果,只需要是否即可。
resultList.addAll(new ArrayList<>(tempList));
return;
} else {
// 剪枝
if(resultList.size() > 0) {
return;
}
// 拼接,可以从字典中任意取一个。可以重复使用
for(String word : wordDict) {
tempList.add(word);
// 回溯
backtrack(target, wordDict, tempList, resultList);
// 移除最后一个
tempList.remove(tempList.size()-1);
}
}
}
private String buildString(List<String> tempList) {
StringBuilder stringBuilder = new StringBuilder();
for(String temp : tempList) {
stringBuilder.append(temp);
}
return stringBuilder.toString();
}
}
但是执行到 38/45
s =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabaabaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
wordDict =
["aa","aaa","aaaa","aaaaa","aaaaaa","aaaaaaa","aaaaaaaa","aaaaaaaaa","aaaaaaaaaa","ba"]
依然会失败。
说明上面的优化没什么大的进步,没有抓住关键。
V3-从字符串切分的角度思考
重新看了一下约束条件,发现其实是 s 的长度比 wordDict 短。
当然了,上面的 case 实际上 wordDict 也没多长。
- 1 <= s.length <= 300
- 1 <= wordDict.length <= 1000
V3.1 字符串切分的回溯实现
这里的一个技巧就是把 wordDict 从 list 转换为 set,将判断 subString 是否存在时间复杂度从 O(N) => O(1)。
public boolean wordBreak(String s, List<String> wordDict) {
// assume s and wordDict are non-empty
return backtracking(0, s, new HashSet<>(wordDict));
}
private boolean backtracking(int depth, String s, Set<String> wordSet) {
int n = s.length();
// accept
if (depth == n) {
return true;
}
for (int i = depth; i < n; ++i) {
// substring[depth, i]
String str = s.substring(depth, i + 1);
//O(1) 判断 str 是否存在
if (wordSet.contains(str)) {
if (backtracking(i + 1, s, wordSet)) {
return true;
}
}
}
return false;
}
V3.2 引入内存化 Backtracking (Memoization)
下面我们用一个例子来看看我们是否可以优化上面的方法。
// String: "abcde" | wordDict: ["a", ...]
depth = 0 ('a')
we have substrings: "a", "ab", "abc", "abcd", "abcde"
对于第一个子字符串“a”,它在 wordDict 中,因此深度变为 1,我们将检查“bcde”是否可破解。
在检查“bcde”是否可破解的过程中,我们可能会检查“cde”、“de”、“e”是否可破解。
一旦我们知道答案,我们就可以缓存它们以供将来使用,无论它们是真还是假。
将来当我们处理完第一个子字符串“a”时,我们将检查“b”,你会看到“cde”、“de”、“e”可能会有很多重复计算。
困难:在回溯式递归函数中使用记忆与其他 DP 记忆相比并不常见。 这是我学到的新形式。 我们需要设置和获取 memo[] 的三个地方。
注意:我们使用布尔值是因为最初我们希望值为空。
实现起来也不难,我们在上面的例子中改一下。
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* @author binbin.hou
* @since 1.0.0
*/
public class T139_WordBreakV4 {
public boolean wordBreak(String s, List<String> wordDict) {
// memo[i] --> S[i...] is breakable or not
Boolean[] mem = new Boolean[s.length()];
// assume s and wordDict are non-empty
return backtracking(mem, 0, s, new HashSet<>(wordDict));
}
private boolean backtracking(Boolean[] mem,
int depth, String s, Set<String> wordSet) {
int n = s.length();
// accept
if (depth == n) {
return true;
}
// 内存化复用
if(mem[depth] != null) {
return mem[depth];
}
for (int i = depth; i < n; ++i) {
// substring[depth, i]
String str = s.substring(depth, i + 1);
//O(1) 判断 str 是否存在
if (wordSet.contains(str)) {
if (backtracking(mem, i + 1, s, wordSet)) {
// 可以,存储
mem[depth] = true;
return true;
}
}
}
// 不可,存储
mem[depth] = false;
return false;
}
}
V3.3 DP
一个字符窜 S 能否拆分为对应的2个子问题 s1 + s2。
如果 s1、s2 本身都可以在 dict 中,那么说明 s 本身就是可以拆分的(满足题目的条件)。
我们定义一个 dp[] 记录 i 的位置是否可以拆分,如果 s(0, i - 1)
或者 s.substring(0, i)
可拆分,则设置为 true,否则为 false。
why?
// String: a b c d e f
In the final round, we are looking into the whole string. We would examine the follow pair of two substrings:
a bcdef
ab cdef
abc def
abcd ef
abcde f
java 实现:
public boolean wordBreak(String s, List<String> wordDict) {
// 初始化 dp[]
boolean[] dp = new boolean[s.length()+1];
dp[0] = true;
Set<String> wordSet = new HashSet<>(wordDict);
for(int i = 1; i <= s.length(); i++) {
for(int j = 0; j < i; j++) {
// s1 = substring(0, j) = dp[j]
// s2 = substring(j, i) = s[j, i - 1]
// 对前面的的每一个 subString 进行比较
String subString = s.substring(j, i);
if(dp[j] && wordSet.contains(subString)) {
// 这个位置断开,是可行的。
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
比较神奇的是,发现性能竟然中规中矩。
TC: O(N^3),这里需要把 substring 的复杂度也考虑进来。
有办法进一步优化 substring,或者说不适用 substring 也能达到目的吗?
V4-backtrack 的优化
其实是可以的。就是一开始我使用的通过 wordDict 中的单词拼接。
虽然 wordDict 的长度可能比 string 多一点,但是和 O(N) 的复杂度对比,基本忽略。
PS: 这里甚至可以下一个判断。根据二者的长度,可以选择对应的算法。
java 实现
这个算法的性能非常高,为什么呢?
public boolean wordBreak(String s, List<String> wordDict) {
// 记录一个位置,是否被问过
boolean[] mem = new boolean[s.length() + 1];
return helper(mem, 0, s, wordDict);
}
/**
*
* @param mem 内存优化
* @param length 字符串长度
* @param target 目标字符串长度
* @param wordList 字典
* @return 结果
*/
private boolean helper(boolean[] mem,
int length,
String target,
List<String> wordList) {
// 终止条件
if(length == target.length()) {
return true;
}
// 已经访问过,说明不可行,直接 false
if(mem[length]) {
return false;
}
// 更新当前位置
mem[length] = true;
for(String word : wordList) {
// 感觉和 substring 差不多,只不过是变成了 startWith
if(target.startsWith(word, length)
&& helper(mem, length + word.length(), target, wordList)) {
return true;
}
}
return false;
}
对比
我们对比一下前面的一个实现 V3.2:
1) 一样的内存记录 mem 数组。
2)这里慢,应该就是慢在这个位置被问过以后,没有立刻返回失败。
public boolean wordBreak(String s, List<String> wordDict) {
// memo[i] --> S[i...] is breakable or not
Boolean[] mem = new Boolean[s.length()];
// assume s and wordDict are non-empty
return backtracking(mem, 0, s, new HashSet<>(wordDict));
}
private boolean backtracking(Boolean[] mem,
int depth, String s, Set<String> wordSet) {
int n = s.length();
// accept
if (depth == n) {
return true;
}
// 内存化复用
if(mem[depth] != null) {
return mem[depth];
}
for (int i = depth; i < n; ++i) {
// substring[depth, i]
String str = s.substring(depth, i + 1);
//O(1) 判断 str 是否存在
if (wordSet.contains(str)) {
if (backtracking(mem, i + 1, s, wordSet)) {
// 可以,存储
mem[depth] = true;
return true;
}
}
}
// 不可,存储
mem[depth] = false;
return false;
}
140. Word Break II
题目
Given a string s and a dictionary of strings wordDict, add spaces in s to construct a sentence where each word is a valid dictionary word. Return all such possible sentences in any order.
Note that the same word in the dictionary may be reused multiple times in the segmentation.
EX
Example 1:
Input: s = "catsanddog", wordDict = ["cat","cats","and","sand","dog"]
Output: ["cats and dog","cat sand dog"]
Example 2:
Input: s = "pineapplepenapple", wordDict = ["apple","pen","applepen","pine","pineapple"]
Output: ["pine apple pen apple","pineapple pen apple","pine applepen apple"]
Explanation: Note that you are allowed to reuse a dictionary word.
Example 3:
Input: s = "catsandog", wordDict = ["cats","dog","sand","and","cat"]
Output: []
Constraints:
1 <= s.length <= 20
1 <= wordDict.length <= 1000
1 <= wordDict[i].length <= 10
s and wordDict[i] consist of only lowercase English letters.
All the strings of wordDict are unique.
思路
这个在上面的题目基础上其实非常简单,采用字典中选择字符串,拼接结果的方式。
java 实现
public List<String> wordBreak(String s, List<String> wordDict) {
List<String> resultList = new ArrayList<>();
// 存放字符串的子集
List<String> tempList = new ArrayList<>();
backtrack(s, wordDict, tempList, resultList);
return resultList;
}
private void backtrack(String target,
List<String> wordDict,
List<String> tempList,
List<String> resultList) {
// 终止条件
String tempString = join(tempList, "");
// 剪枝
if(tempString.length() > target.length()) {
return;
} else if(tempString.equals(target)) {
// 后来发现,并不需要用完所有的单词。
// 这里不需要返回结果,只需要是否即可。
resultList.add(join(tempList, " "));
return;
} else {
// 拼接,可以从字典中任意取一个。可以重复使用
for(String word : wordDict) {
// 这里只选择匹配的字符串
if(target.startsWith(word, tempString.length())) {
tempList.add(word);
// 回溯
backtrack(target, wordDict, tempList, resultList);
// 移除最后一个
tempList.remove(tempList.size()-1);
}
}
}
}
private String join(List<String> tempList,
String splitter) {
if(tempList.size() <= 0) {
return "";
}
StringBuilder stringBuilder = new StringBuilder();
for(int i = 0; i < tempList.size()-1; i++) {
stringBuilder.append(tempList.get(i))
.append(splitter);
}
stringBuilder.append(tempList.get(tempList.size()-1));
return stringBuilder.toString();
}
参考资料
https://leetcode.com/problems/word-break
https://leetcode.com/problems/word-break-ii/description/
https://leetcode.com/problems/word-break/solutions/434582/java-solutions-backtracking-memoization-dp-with-detailed-exp/
https://leetcode.com/problems/word-break/solutions/43970/concise-dfs-backtracking-solution/