文章目录
题目描述:给定一个字符串
s
和一个字符串集合
wordDict
。求解,是否能用
wordDict
中的字符串,拼凑出字符串
s
?
(
wordDict
中的字符串可以使用任意次)
如 s
= "leetcode"
,wordDict
= ["leet", "code"]
,返回 true
如 s
= applepenapple
,wordDict
= ["pen", "apple"]
,返回 true
通俗的说,就是先给你一堆字符串,问你能否用这一堆字符串,拼接出另一个字符串。
解法一:DFS暴搜
最直观的做法就是使用暴力,把所有可能的情况都枚举一遍。如果字符串s
能够拆分成若干个wordDict
中的字符串。那从s
的最左侧出发往右走,一定能先拆出第一个字符串。
暴力搜索我们一般会用DFS来做,DFS时,中间节点是可能的情况分支,而最终的答案落在叶子节点。
先定义这样一个函数:dfs(int begin)
,其含义是,求解字符串s
从begin
下标开始的子串,能否拆分成若干个wordDict
中的字符串。
那我们最终的答案就是求 dfs(0)
。
中间的过程,可以枚举下标i
,从begin
开始枚举到s
的最后一个位置。当s
在[begin,i]
的子串在wordDict
中出现,则我们拆出了左侧的第一个字符串,此时再递归的求解 dfs(i + 1)
即可。
递归退出的条件是 begin == s.length()
,此时已经将整个字符串s
全部拆完。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
return dfs(s, 0, new HashSet<>(wordDict));
}
private boolean dfs(String s, int begin, Set<String> wordDict) {
if (begin == s.length()) return true;
for (int end = begin + 1; end <= s.length(); end++) {
String subStr = s.substring(begin, end);
if (wordDict.contains(subStr) && dfs(s, end, wordDict)) return true;
}
return false;
}
}
需要注意,substring(i,j)
方法的第二个参数是exclusive
的,若调用的是substring(3,5)
,子串下标范围是 [3, 5)
,即[3, 4]
。
暴力法的时间复杂度非常糟糕,因为它会对相同的状态进行多次重复的计算。无法通过全部的 test case。
下面要对DFS的过程进行优化。
解法二:DFS + 记忆化
比如上面截图的 test case,会重复计算 dfs(2)
所以,我们额外开一个Booolean
数组st
,保存已经计算过的位置的结果,当调用dfs(i)
搜索位置i
时,如果st[i] != null
,则说明i
位置已经被搜索过,则直接返回。以此来减少重复的计算。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
return dfs(s, 0, new HashSet<>(wordDict), new Boolean[s.length()]);
}
private boolean dfs(String s, int begin, Set<String> wordDict, Boolean[] st) {
if (begin == s.length()) return true; // 到达叶子节点
if (st[begin] != null) return st[begin]; // 记忆化
for (int end = begin + 1; end <= s.length(); end++) {
String subStr = s.substring(begin, end);
if (wordDict.contains(subStr) && dfs(s, end, wordDict, st)) {
return st[begin] = true;
}
}
return st[begin] = false;
}
}
解法三:BFS
上面的DFS过程,也可以用BFS来做,用一个队列来存储当前搜索到的位置即可,用一个boolean
数组st
,来记录哪些位置已经被访问过,对于已经被访问过的位置i
,不需要重复处理。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] st = new boolean[s.length()];
Set<String> wordSet = new HashSet<>(wordDict);
Queue<Integer> q = new LinkedList<>();
q.offer(0); // 先把
while (!q.isEmpty()) {
int begin = q.poll(); // 获取上一个节点的起始位置
if (st[begin]) continue;
for (int end = begin + 1; end <= s.length(); end++) {
if (wordSet.contains(s.substring(begin, end))) {
// 拆出一个字符串
if (end == s.length()) return true; // 结束
if (st[end]) continue; // 重复, 跳过
q.offer(end);
}
}
st[begin] = true;
}
return false;
}
}
动态规划
这道题同样适合用动规来做。我们这样来定义状态f(i)
:
- 当字符串
s
的子串[0, i]
能够被拆分,则f(i) = true
- 当字符串
s
的子串[0, i]
不能被拆分,则f(i) = false
即 f(i)
代表着字符串s
的某个前缀,是否能拆分。
对于状态转移,这样来考虑:我们枚举所有能进行拆分的位置(分割点),看能否转移过去即可。
求f(i)
时,我们枚举分割点j ∈ [0, i - 1]
,字符串被切割成了两部分:[0, j]
,[j + 1, i]
。只需要满足 f(j) = true
并且子串 [j + 1, i]
是 wordDict
中的字符串,则 f(i) = true
(前半部分能够被拆分,且后半部分是wordDict
中的字符串)。若枚举了所有的j
都无法找到这样的情况,则f(i) = false
。
具体的代码实现中:
由于f(i)
的计算需要依赖于i
之前的状态f
,所以我们开的数组大小为 s.length() + 1
。
并且用 f(1)
来表示字符串s
的[0, 0]
能否拆分(长度为1)
f(2)
表示 [0, 1]
能否拆分(长度为2)。
这和上面的描述有点不同,主要是为了方便代码实现。
还要注意初始化 f(0) = true
,可以理解为wordDict
中总是有一个空字符串。(下面的代码中,注意看注释,对于理解很重要)
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] f = new boolean[s.length() + 1];
f[0] = true;
Set<String> wordSet = new HashSet<>(wordDict);
for (int i = 1; i <= s.length() ; i++) {
// 计算所有的 f(i)
for (int j = 0; j < i; j++) {
// 注意, 【i, j 用来取 f 时】, 与 【i, j 用来对字符串切割时】 含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
// 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
// 切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1] (右侧只剩一个位置) (右侧至少要剩一个位置, 虽然代码里写成 j <= i 的话, 也不影响答案的正确性, 但是会多进行一次迭代)
if (f[j] && wordSet.contains(s.substring(j, i))) {
f[i] = true;
break;
}
}
}
return f[s.length()];
}
}
优化:上面的动规可以加一个小的优化。由于我们在求解f(i)
时,是枚举了i
之前的所有分割点j ∈ [0, i - 1]
,分割后,前面的部分直接用f(j)
来判断,后面的子串则判断其是否存在于wordDict
中。那我们可以先对wordDict
中的所有字符串,求一个最大长度。
则在进行分割时,右侧的子串如果超过这个最大长度,则一定不存在了,于是可以提前终止循环。(这种方式,分割点要从最右侧开始取,从最右侧开始取,右侧子串的长度才是递增的,才能在达到最大长度后提前终止)
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] f = new boolean[s.length() + 1];
f[0] = true;
int maxLen = 0;
Set<String> wordSet = new HashSet<>();
for (String w : wordDict) {
wordSet.add(w);
maxLen = Math.max(maxLen, w.length());
}
for (int i = 1; i <= s.length() ; i++) {
// 计算所有的 f(i)
// j >= 0 不能省掉, 因为可能maxLen比当前能切割出的最长子串还长, 那样 j 就会取到 负数
for (int j = i - 1; j >= 0 && i - j <= maxLen; j--) {
// 注意, 【i, j 用来取 f 时】, 与 【i, j 用来对字符串切割时】 含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
// f(i) 表示下标的第i个位置 (字符串下标应当是i - 1), 而在 substring 里面用来截取字符串时, 其实应该截取到下标i - 1, 而substring的第二个参数恰好是 exclusive
// 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
// 切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1] (右侧只剩一个位置)
if (f[j] && wordSet.contains(s.substring(j, i))) {
f[i] = true;
break;
}
}
}
return f[s.length()];
}
}
再狠一点的,也可以再求一个最小长度,把两个最值都用上。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] f = new boolean[s.length() + 1];
f[0] = true;
int maxLen = 0, minLen = Integer.MAX_VALUE;
Set<String> wordSet = new HashSet<>();
for (String w : wordDict) {
wordSet.add(w);
maxLen = Math.max(maxLen, w.length());
minLen = Math.min(minLen, w.length());
}
for (int i = 1; i <= s.length() ; i++) {
// 计算所有的 f(i)
// 从最小长度开始
for (int j = i - minLen; j >= 0 && i - j <= maxLen; j--) {
// 注意, 【i, j 用来取 f 时】, 与 【i, j 用来对字符串切割时】 含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
// f(i) 表示下标的第i个位置 (字符串下标应当是i - 1), 而在 substring 里面用来截取字符串时, 其实应该截取到下标i - 1, 而substring的第二个参数恰好是 exclusive
// 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
// 切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1] (右侧只剩一个位置)
if (f[j] && wordSet.contains(s.substring(j, i))) {
f[i] = true;
break;
}
}
}
return f[s.length()];
}
}
但是和只用一个最值的差别并不大。
(完)