力扣解题-139. 单词拆分
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 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
提示:
1 <= s.length <= 300
1 <= wordDict.length <= 1000
1 <= wordDict[i].length <= 20
s 和 wordDict[i] 仅由小写英文字母组成
wordDict 中的所有字符串 互不相同
Related Topics
字典树、记忆化、数组、哈希表、字符串、动态规划
第一次解答
解题思路
核心方法:动态规划(DP)基础版,通过定义dp数组记录子串是否可拆分,利用哈希集合快速判断子串是否在字典中,时间复杂度O(n²)(n为字符串长度)、空间复杂度O(n + m)(m为字典单词数),是本题最直观、易实现的经典解法(注:原代码缺少return dp[sl];,属于笔误,不影响核心逻辑理解)。
核心逻辑拆解
单词拆分的核心是“逐步验证子串是否可拆分”:
- DP数组定义:
dp[j]表示字符串s的前j个字符(即s[0..j-1])能否被字典中的单词拼接而成; - 初始条件:
dp[0] = true(空字符串可以被拆分,作为递归的基础条件); - 状态转移逻辑:
- 对于每个位置
j(代表前j个字符),遍历所有可能的分割点i(0 ≤ i < j); - 若
dp[i]为true(前i个字符可拆分),且子串s[i..j-1]存在于字典中,则dp[j]为true(前j个字符可拆分);
- 对于每个位置
- 哈希优化:将字典存入HashSet,将子串存在性判断的时间复杂度从O(m)降至O(1)。
具体执行逻辑
- 字典预处理:将
wordDict转换为HashSetset,方便快速查询; - DP数组初始化:
- 长度为
s.length()+1(覆盖0到s.length的所有子串长度); - 所有元素默认false,仅
dp[0] = true(空字符串可拆分);
- 长度为
- 双层循环验证:
- 外层循环
j:遍历所有子串长度(从1到s.length); - 内层循环
i:遍历所有可能的分割点(从0到j-1); - 若
dp[i]为true且s.substring(i,j)在set中,标记dp[j] = true并跳出内层循环;
- 外层循环
- 结果返回:
dp[s.length()]即为整个字符串是否可拆分的结果。
执行流程可视化(以示例1 s="leetcode"、wordDict=["leet","code"]为例)
| j(子串长度) | i(分割点) | dp[i] | 子串s[i..j-1] | 是否在字典 | dp[j] | 说明 |
|---|---|---|---|---|---|---|
| 4 | 0 | true | "leet" | 是 | true | 前4个字符可拆分 |
| 8 | 4 | true | "code" | 是 | true | 前8个字符可拆分 |
| 结束 | - | - | - | - | true | 返回true |
关键细节说明
- 子串范围:
s.substring(i,j)获取的是s中从索引i(包含)到j(不包含)的子串,对应前j个字符的后j-i个字符; - 提前终止内层循环:找到一个满足条件的
i后立即break,避免无效遍历; - 边界条件:
dp[0] = true是核心,没有这个初始条件,所有后续验证都无法成立; - 原代码补充:需在方法末尾添加
return dp[sl];才能正确返回结果(笔误不影响核心逻辑)。
性能说明
- 时间复杂度:O(n²)(外层n次循环,内层平均n/2次循环,子串截取为O(k),k为子串长度,总体仍为O(n²));
- 空间复杂度:O(n + m)(DP数组O(n) + HashSet存储字典O(m));
- 优势:
- 逻辑直观,完全贴合动态规划“状态定义-初始条件-状态转移”的经典范式;
- 哈希集合优化查询效率,比直接遍历字典更高效;
- 代码简洁,易理解和实现。
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> set = new HashSet<>(wordDict);
int sl=s.length();
//初始化的时候,默认都是false
boolean dp[]=new boolean[sl+1];
//设置空字符串为true
dp[0]=true;
//遍历字符串,分割字符看是否有匹配的
for(int j=1;j<=sl;j++){
for(int i=0;i<j;i++){
if(dp[i] && set.contains(s.substring(i,j))){
dp[j]=true;
break;
}
}
}
return dp[sl]; // 补充原代码缺失的返回语句
}
示例解答
解题思路
解法1:动态规划优化版(减少无效遍历,O(n×L)时间)
核心方法:在基础DP的基础上,限制内层循环的范围为“当前位置j往前最多字典最长单词长度”,避免无效的子串截取,将时间复杂度优化至O(n×L)(L为字典最长单词长度),适合长字符串场景。
代码实现
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> set = new HashSet<>(wordDict);
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true;
// 找到字典中最长单词的长度,限制内层循环范围
int maxWordLen = 0;
for (String word : wordDict) {
maxWordLen = Math.max(maxWordLen, word.length());
}
for (int j = 1; j <= n; j++) {
// 内层循环仅遍历j-maxWordLen到j-1,避免无效子串
int start = Math.max(0, j - maxWordLen);
for (int i = start; i < j; i++) {
if (dp[i] && set.contains(s.substring(i, j))) {
dp[j] = true;
break;
}
}
}
return dp[n];
}
核心逻辑说明
- 最长单词长度优化:先遍历字典找到最长单词长度
maxWordLen; - 内层循环范围限制:对于每个
j,仅遍历i = max(0, j-maxWordLen)到j-1,因为超过maxWordLen的子串不可能在字典中; - 性能提升:当字典最长单词长度为20时,内层循环最多执行20次,而非j次,n=300时循环次数从约4.5万次降至6000次。
性能说明
- 时间复杂度:O(n×L)(n为字符串长度,L为字典最长单词长度);
- 空间复杂度:O(n + m)(与基础版一致);
- 优势:
- 大幅减少内层循环次数,长字符串场景下效率显著提升;
- 逻辑与基础版一致,仅增加简单的范围限制,易理解。
解法2:记忆化递归(Top-Down,O(n×L)时间)
核心方法:通过递归+记忆化缓存,自顶向下验证子串是否可拆分,避免重复计算,逻辑更贴合“拆分字符串”的直观思路。
代码实现
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> set = new HashSet<>(wordDict);
// 记忆化缓存:-1未计算,0不可拆分,1可拆分
int[] memo = new int[s.length()];
Arrays.fill(memo, -1);
return dfs(s, 0, set, memo);
}
private boolean dfs(String s, int start, Set<String> set, int[] memo) {
// 递归终止:已遍历完整个字符串,可拆分
if (start == s.length()) {
return true;
}
// 已计算过,直接返回结果
if (memo[start] != -1) {
return memo[start] == 1;
}
// 遍历所有可能的结束位置(最多到字典最长单词长度)
int maxEnd = Math.min(start + 20, s.length()); // 题目限制单词长度≤20
for (int end = start + 1; end <= maxEnd; end++) {
String sub = s.substring(start, end);
if (set.contains(sub) && dfs(s, end, set, memo)) {
memo[start] = 1; // 标记可拆分
return true;
}
}
memo[start] = 0; // 标记不可拆分
return false;
}
核心逻辑说明
- 记忆化缓存:
memo[start]记录从start索引开始的子串是否可拆分,避免重复递归; - 递归终止条件:
start == s.length()时返回true(已拆分完所有字符); - 递归验证:遍历从
start开始的所有可能子串(长度1到20),若子串在字典中且剩余部分可拆分,则当前子串可拆分; - 缓存结果:将递归结果存入
memo,后续直接使用。
性能说明
- 时间复杂度:O(n×L)(每个起始位置最多遍历L次,L≤20);
- 空间复杂度:O(n)(递归栈深度+memo数组);
- 优势:
- 自顶向下的递归逻辑更贴合“拆分字符串”的直观思路;
- 记忆化避免重复计算,效率与优化版DP持平;
- 劣势:递归栈有额外开销,n=300时栈深度最大为300(无溢出风险)。
总结
- 基础DP法(第一次解答):O(n²)时间+O(n+m)空间,逻辑直观,易实现,是本题的核心解法;
- DP优化版:O(n×L)时间+O(n+m)空间,限制内层循环范围,长字符串场景更高效;
- 记忆化递归:O(n×L)时间+O(n)空间,自顶向下解题,逻辑更贴合直观思路;
- 关键技巧:
- 核心思想:单词拆分的核心是“子问题可拆分+当前子串在字典中”,DP数组记录子问题结果;
- 效率优化:限制内层循环范围至字典最长单词长度,可大幅减少无效计算;
- 结构选择:工程中优先选DP优化版(无递归开销),学习阶段可通过记忆化递归理解核心逻辑。