文章目录
题目链接
https://leetcode.cn/problems/word-break/description/
题目说明
给你一个字符串 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
约束条件
- 1 <= s.length <= 300
- 1 <= wordDict.length <= 1000
- 1 <= wordDict[i].length <= 20
- s 和 wordDict[i] 仅由小写英文字母组成
- wordDict 中的所有字符串互不相同
解题思路概览
单词拆分问题
方法1: 动态规划
方法2: 记忆化搜索
方法3: BFS广度优先搜索
自底向上构建
状态转移方程
自顶向下递归
缓存中间结果
队列存储位置
层次遍历
方法一:动态规划
原理讲解
动态规划是解决此题的最优方法。核心思想是:如果字符串的前 i 个字符可以被拆分,且从位置 i 到 j 的子串在字典中,那么前 j 个字符也可以被拆分。
定义状态:
dp[i]表示字符串s的前i个字符是否可以被拆分成字典中的单词
状态转移方程:
dp[i] = dp[j] && wordDict.contains(s.substring(j, i))
其中 0 <= j < i
执行流程
否
是
是
否
开始
初始化dp数组, dp0=true
遍历i从1到n
遍历j从0到i-1
dp[j]==true?
s[j:i]在字典中?
dp[i]=true, break
返回dp[n]
Java代码实现
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 将字典转换为HashSet,提高查找效率
Set<String> wordSet = new HashSet<>(wordDict);
int n = s.length();
// dp[i]表示s的前i个字符是否可以被拆分
boolean[] dp = new boolean[n + 1];
dp[0] = true; // 空字符串可以被拆分
// 遍历字符串的每个位置
for (int i = 1; i <= n; i++) {
// 尝试所有可能的拆分点
for (int j = 0; j < i; j++) {
// 如果前j个字符可以拆分,且j到i的子串在字典中
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break; // 找到一种拆分方式即可
}
}
}
return dp[n];
}
}
复杂度分析
- 时间复杂度:O(n² × m),其中 n 是字符串长度,m 是字典中最长单词的长度(substring操作)
- 空间复杂度:O(n),dp数组的空间
方法二:记忆化搜索(DFS + 缓存)
原理讲解
记忆化搜索采用自顶向下的递归思路,从字符串的起始位置开始,尝试匹配字典中的每个单词。为了避免重复计算,使用一个缓存数组记录每个位置的搜索结果。
执行流程
字典 缓存数组 DFS递归 主函数 字典 缓存数组 DFS递归 主函数 dfs(0) 检查memo[0] 未计算 遍历字典单词 匹配"leet" dfs(4) 检查memo[4] 未计算 匹配"code" 到达末尾 memo[4]=true memo[0]=true 返回true
Java代码实现
java
class Solution {
private Boolean[] memo;
private Set<String> wordSet;
private String s;
public boolean wordBreak(String s, List<String> wordDict) {
this.s = s;
this.wordSet = new HashSet<>(wordDict);
this.memo = new Boolean[s.length()];
return dfs(0);
}
private boolean dfs(int start) {
// 到达字符串末尾,说明可以完全拆分
if (start == s.length()) {
return true;
}
// 如果已经计算过,直接返回缓存结果
if (memo[start] != null) {
return memo[start];
}
// 尝试从start位置开始匹配字典中的每个单词
for (String word : wordSet) {
int end = start + word.length();
// 检查是否越界以及是否匹配
if (end <= s.length() && s.substring(start, end).equals(word)) {
// 递归检查剩余部分
if (dfs(end)) {
memo[start] = true;
return true;
}
}
}
// 所有尝试都失败
memo[start] = false;
return false;
}
}
复杂度分析
- 时间复杂度:O(n × k × m),其中 n 是字符串长度,k 是字典大小,m 是平均单词长度
- 空间复杂度:O(n),递归栈和缓存数组的空间
方法三:BFS广度优先搜索
原理讲解
将问题看作图的遍历问题,每个位置是一个节点,如果从位置 i 到位置 j 的子串在字典中,则存在一条从 i 到 j 的边。使用BFS从位置0开始,看能否到达位置n。
执行流程
leet
code
le
et
code
位置0
位置4
位置8/结束
位置2
位置4
Java代码实现
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet<>(wordDict);
Queue<Integer> queue = new LinkedList<>();
boolean[] visited = new boolean[s.length()];
queue.offer(0);
while (!queue.isEmpty()) {
int start = queue.poll();
// 如果这个位置已经访问过,跳过
if (visited[start]) {
continue;
}
visited[start] = true;
// 尝试从start位置开始的所有可能单词
for (int end = start + 1; end <= s.length(); end++) {
if (wordSet.contains(s.substring(start, end))) {
// 如果到达末尾,返回true
if (end == s.length()) {
return true;
}
// 将新位置加入队列
queue.offer(end);
}
}
}
return false;
}
}
复杂度分析
- 时间复杂度:O(n² × m),最坏情况下需要访问所有位置,每个位置需要检查所有可能的子串
- 空间复杂度:O(n),队列和visited数组的空间
优化技巧
动态规划优化版本
可以通过记录字典中最长单词的长度来减少内层循环次数:
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet<>(wordDict);
int n = s.length();
// 找出字典中最长单词的长度
int maxLen = 0;
for (String word : wordDict) {
maxLen = Math.max(maxLen, word.length());
}
boolean[] dp = new boolean[n + 1];
dp[0] = true;
for (int i = 1; i <= n; i++) {
// 只需要检查最长单词长度范围内的拆分点
for (int j = Math.max(0, i - maxLen); j < i; j++) {
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
}
方法对比分析
| 方法 | 时间复杂度 | 空间复杂度 | 实现难度 | 性能表现 | 适用场景 |
|---|---|---|---|---|---|
| 动态规划 | O(n² × m) | O(n) | 中等 | 最优 | 通用场景,推荐使用 |
| 动态规划优化 | O(n × maxLen × m) | O(n) | 中等 | 优秀 | 字典单词长度差异大时效果显著 |
| 记忆化搜索 | O(n × k × m) | O(n) | 中等 | 良好 | 字典较小时表现好 |
| BFS | O(n² × m) | O(n) | 简单 | 一般 | 理解图论思想的教学场景 |
性能对比说明
- 动态规划方法是最稳定和推荐的解法,代码简洁,性能优秀
- 优化后的动态规划在字典单词长度较短时能显著提升性能
- 记忆化搜索在字典规模较小时表现良好,但递归调用有额外开销
- BFS方法直观易懂,但在实际性能上略逊于动态规划
选择建议
- 面试场景:优先使用动态规划,代码简洁且易于解释
- 性能要求高:使用优化版动态规划
- 学习理解:可以从BFS开始,逐步过渡到动态规划
- 字典特别小:记忆化搜索也是不错的选择
单词拆分问题是一道经典的动态规划题目,核心在于理解状态定义和转移方程。通过将大问题分解为子问题,利用已计算的结果来避免重复计算,可以高效地解决这个问题。掌握这道题的多种解法,有助于深入理解动态规划、记忆化搜索和BFS等算法思想。