题目描述
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s,则返回 true。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例:
- 输入:
s = "leetcode", wordDict = ["leet", "code"]→ 输出:true("leetcode" 可以由 "leet" 和 "code" 拼接成) - 输入:
s = "applepenapple", wordDict = ["apple", "pen"]→ 输出:true("applepenapple" 可以由 "apple" "pen" "apple" 拼接成) - 输入:
s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]→ 输出:false
解题思路总览
| 方法 | 思路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 动态规划 | dp[i] 表示字符串前 i 个字符能否被拆分,状态转移 dp[i] = dp[j] && wordDict.contains(s[j:i]) |
O(n^2 × L) | O(n) |
| BFS | 从起点开始,用字典中的单词扩展,能到达终点则返回 true | O(n × L) | O(n) |
| Trie + BFS | 用字典树优化字符串查找,加速 BFS | O(n × L) | O(n + dict_size) |
本题采用动态规划方法。
完整代码
cpp
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> set(wordDict.begin(), wordDict.end());
int n = s.size();
vector<bool> dp(n + 1, false);
dp[0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && set.find(s.substr(j, i - j)) != set.end()) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
};
算法流程图
输入: s = "leetcode", wordDict = ["leet", "code"]
初始化:
set = {"leet", "code"}
n = 8
dp[0] = true
dp[1...8] = false
i = 1:
j = 0: dp[0]=true, 检查 s[0:1] = "l"
"l" 不在 set 中, 继续
j = 1: dp[1]=false, 跳过
... (j=2...7 同理)
dp[1] = false
i = 2:
j = 0: dp[0]=true, 检查 s[0:2] = "le"
"le" 不在 set 中, 继续
j = 1: dp[1]=false, 跳过
... (j=2...7 同理)
dp[2] = false
i = 4:
j = 0: dp[0]=true, 检查 s[0:4] = "leet"
"leet" 在 set 中!
dp[4] = true, break
dp[4] = true
i = 8:
j = 0: dp[0]=true, 检查 s[0:8] = "leetcode"
"leetcode" 不在 set 中, 继续
j = 1: dp[1]=false, 跳过
j = 2: dp[2]=false, 跳过
j = 3: dp[3]=false, 跳过
j = 4: dp[4]=true, 检查 s[4:8] = "code"
"code" 在 set 中!
dp[8] = true, break
dp[8] = true
最终 dp[8] = true
输出: true
逐行解析
cpp
unordered_set<string> set(wordDict.begin(), wordDict.end());
含义: 将字典列表转换为哈希集合,提高单词查找效率,O(1) 时间判断一个字符串是否在字典中。
cpp
int n = s.size();
含义: 记录字符串长度,方便后续循环使用。
cpp
vector<bool> dp(n + 1, false);
含义: 创建大小为 n+1 的布尔数组,dp[i] 表示字符串前 i 个字符能否被拆分成字典中的单词。dp[0] = true 表示空字符串可以被拆分(作为起始条件)。
cpp
dp[0] = true;
含义: 基础情况,空字符串可以被拆分,这是一个重要的递推起点。
cpp
for (int i = 1; i <= n; i++)
含义: 从 1 到 n 依次计算每个前缀是否能被拆分。dp[i] 对应字符串 s[0:i)。
cpp
for (int j = 0; j < i; j++)
含义: 枚举所有可能的分割点 j,将前 i 个字符拆分为 s[0:j) 和 s[j:i) 两部分。
cpp
if (dp[j] && set.find(s.substr(j, i - j)) != set.end())
含义: 状态转移条件。只有当左边部分 s[0:j) 能被拆分(dp[j] = true)且右边部分 s[j:i) 在字典中时,前 i 个字符才能被拆分。
cpp
dp[i] = true;
break;
含义: 找到一个可行的分割点后,直接 break 跳出内层循环,因为只需要知道是否存在可行方案,不需要继续枚举。
cpp
return dp[n];
含义: 返回字符串前 n 个字符(即整个字符串)是否能被拆分。
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n^2 × L) | 外层循环 n 次,内层循环最多 n 次,每次 substr 和 find 操作 O(L),L 为字符串平均长度 |
| 空间复杂度 | O(n + dict_size) | dp 数组 O(n),哈希集合存储字典 O(dict_size) |
面试追问 FAQ
| 问题 | 答案 |
|---|---|
为什么不直接在内层循环中判断 s[j:i] 是否在字典? |
因为 Java/Python 的字符串切片是 O(L),C++ 的 substr 也是 O(L),所以总复杂度是 O(n^2 × L) |
| 如何优化到 O(n × L)? | 使用 Trie(字典树)存储字典,在 dp 过程中从位置 j 开始在 Trie 中匹配,而不是每次都用 substr |
dp[0] = true 是什么意思? |
表示空字符串可以被拆分,作为递推的起点。例如要拆分 "leetcode",当 j=4 时,检查 "leet" 是否在字典 |
| 完全背包和这道题有什么关系? | 这道题本质上是字典的完全背包问题:字典中的单词是"物品",字符串是"背包容量",但关注的是能否装满 |
| 如果字典中有大量单词,如何优化空间? | 使用 Trie 代替哈希集合,可以将字典的空间从 O(dict_size × L) 优化到 O(dict_size × L) 但查找更快 |
| 如何输出具体的拆分方案? | 额外记录每个 dp[i] = true 是从哪个 j 转移来的,最后从 dp[n] 回溯得到所有单词 |
相关题目
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 139 | 单词拆分 | 中等 | 完全背包/字典树 |
| 140 | 单词拆分 II | 困难 | 字典树 + DFS |
| 322 | 零钱兑换 | 中等 | 完全背包 |
| 279 | 完全平方数 | 中等 | 动态规划 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 完全背包动态规划,判断字符串前缀能否被字典拆分 |
| 状态定义 | dp[i] = 字符串前 i 个字符能否被拆分成字典中的单词 |
| 状态转移 | dp[i] = dp[j] && wordDict.contains(s[j:i]) |
| 初始化 | dp[0] = true(空字符串可以被拆分) |
| 关键技巧 | 一旦找到可行分割点就 break,减少无效计算 |