文章目录


题目描述:给定一个字符串

​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。

LeetCode 139. 单词拆分_dfs

下面要对DFS的过程进行优化。

解法二:DFS + 记忆化

比如上面截图的 test case,会重复计算 ​​dfs(2)​

LeetCode 139. 单词拆分_leetcode_02

所以,我们额外开一个​​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;
}
}

LeetCode 139. 单词拆分_leetcode_03

解法三: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;
}
}

LeetCode 139. 单词拆分_字符串_04

动态规划

这道题同样适合用动规来做。我们这样来定义状态​​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()];
}
}

LeetCode 139. 单词拆分_字符串_05

优化:上面的动规可以加一个小的优化。由于我们在求解​​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()];
}
}

LeetCode 139. 单词拆分_字符串_06

再狠一点的,也可以再求一个最小长度,把两个最值都用上。

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()];
}
}

LeetCode 139. 单词拆分_算法_07

但是和只用一个最值的差别并不大。

LeetCode 139. 单词拆分_dfs_08

(完)