139. 单词拆分 
题目描述
给定一个字符串 s 和一个字符串列表 wordDict,判断能否使用字典中的单词拼接出完整的 s。
字典中的单词可以重复使用,不要求每个单词都被使用。
示例:
1. 从“最后一个单词”理解问题
假设要判断前 i 个字符 s[0..i-1] 能否被拆分,可以枚举最后一个单词从位置 j 开始:
要让整个前缀可以拆分,必须同时满足:
s[0..j-1]已经能够被字典单词拆分。s[j..i-1]本身是字典中的单词。
只要存在一个位置 j 同时满足这两个条件,前 i 个字符就可以拆分。
这就是本题动态规划转移的来源。
2. 状态定义
定义长度为 s.length + 1 的布尔数组:
特别注意,i 表示前缀长度,不是字符下标:
因此最终答案是:
3. 为什么 dp[0] = true
初始化:
这里不是说题目一定把空字符串视为一个字典单词,而是说:在还没有选取任何字符时,空前缀已经被成功拆分。
它相当于后续状态的起点。例如,如果 s.slice(0, 4) === "leet" 在字典中:
如果 dp[0] 初始化为 false,第一个单词将永远无法建立任何可达状态,后续所有位置也都会是 false。
4. 状态转移方程
对于每个前缀终点 i,枚举最后一个单词的起点 j:
写成代码:
一旦找到合法的 j,就可以停止当前内层循环,因为本题只判断是否可拆分,不需要统计所有拆分方案。
5. 为什么必须先检查 dp[j]
仅仅发现 s.slice(j, i) 在字典中还不够,前面的 s.slice(0, j) 也必须能够被完整拆分。
例如:
虽然 s.slice(1, 5) === "leet"、s.slice(5, 9) === "code" 都在字典中,但开头的 "x" 无法拆分,所以整个字符串仍然是 false。
dp[j] 表示位置 j 是一个真正可达的切分点。只有从可达位置出发找到字典单词,才能建立新的可达状态。
6. 为什么把词典转换为 Set
原始词典是数组。如果使用:
每次判断都需要线性扫描词典。把它转换为 Set:
可以更直接地表达“某个单词是否存在”,查询的平均时间复杂度也更低。
7. 推荐代码实现
这段代码和状态转移公式几乎完全一致:
第一次学习时不需要加入额外剪枝,先把 i 理解成当前前缀长度、把 j 理解成最后一个单词的起点即可。
8. 示例推演:leetcode
初始状态:
逐步计算:
最终:
dp[8] === true,所以 "leetcode" 可以被拆分。
9. 失败示例:catsandog
可达前缀包括:
虽然末尾的 "dog" 在字典中,但它从位置 6 开始,而 dp[6] === false:
从可达位置 7 开始只剩下 "og",它不在字典中。因此 dp[9] === false。
这个例子说明:找到字典单词只是转移的一半,还必须确认它前面的切分点可达。
10. 正确性说明
可以按照前缀长度进行归纳证明。
如果 dp[i] === true,前 i 个字符一定可以拆分
算法只会在某个 dp[j] === true 且 s.slice(j, i) 是字典单词时,把 dp[i] 设为 true。根据 dp[j] 的定义,前 j 个字符可以拆分;再拼接最后一个字典单词,就能拆分前 i 个字符。
如果前 i 个字符可以拆分,算法一定会得到 dp[i] === true
任取一种合法拆分,设最后一个单词从位置 j 开始。那么前 j 个字符也可以合法拆分,因此按照归纳假设 dp[j] === true;最后一个单词 s.slice(j, i) 在字典中。算法枚举到这个 j 时一定会把 dp[i] 设为 true。
因此,dp[i] 与“前 i 个字符可以拆分”等价,dp[s.length] 就是正确答案。
11. 复杂度分析
设 n 为字符串 s 的长度。
- 外层枚举前缀终点,执行
n次。 - 内层枚举最后一个单词的起点,最多执行
n次。 - 在常见算法分析中,把哈希集合查询视为平均
O(1),因此通常将时间复杂度写为O(n²)。 - 如果严格计入 JavaScript 中
slice创建子串以及字符串哈希、比较的字符成本,单次候选检查最多需要O(n),最坏时间上界为O(n³)。
空间复杂度:
dp数组需要O(n)。- 哈希集合需要保存字典,共
O(D)字符空间,其中D是字典所有单词的总字符数。 - 临时子串最多占用
O(n)空间。
12. 可选优化:限制单词长度
基础版本已经足够清晰,也可以通过本题。如果希望减少无意义的切分检查,可以利用字典中的最长单词长度。
假设最长单词长度为 maxWordLength,那么最后一个单词不可能长于它。计算 dp[end] 时,起点无需从 0 开始,只需枚举:
优化后的核心循环是:
设最长单词长度为 L,候选检查次数可从 O(n²) 减少为 O(n × L)。这个剪枝不改变 DP 状态和转移逻辑,只缩小了起点枚举范围,因此更适合作为理解基础解法后的进一步优化。
13. 记忆化搜索写法
也可以把 start 定义为当前尚未拆分部分的起点,并使用 DFS 尝试每个字典单词:
如果没有 memo,同一个起点会被不同拆分路径反复搜索,最坏可能产生指数级分支。记忆化后,每个起点最多完整计算一次。
两种方法的视角不同:
- 自底向上 DP:判断哪些前缀长度可达。
- 记忆化搜索:判断从当前位置开始的后缀能否完成拆分。
14. 边界条件与易错点
边界条件:
- 字典单词可以重复使用,因此不能在匹配后从字典中删除单词。
wordDict中没有重复单词,但即使转换为Set也不会影响结果。- 一个单词可能就是完整的
s,此时通过dp[0]直接建立dp[n]。 - 单词可以有公共前缀,例如
"car"和"cars",算法必须保留所有可能的可达切分点。
易错点:
dp[i]表示前i个字符,不是字符下标i。- 必须初始化
dp[0] = true。 - JavaScript 的
slice(start, end)不包含end,正好对应前缀长度定义。 - 判断候选单词前必须确认
dp[start] === true。 - 不要使用贪心策略,例如每次选择最长或最短的匹配单词;局部选择可能阻断后续拆分。
- 使用普通 DFS 时必须增加记忆化,否则会重复计算同一后缀。
15. 为什么贪心不可靠
假设每次都选择当前能够匹配的最长单词,一旦后续失败,还需要回头尝试其他切分,因此本质上仍是搜索。
例如:
选择较长的 "car" 后只剩下 "s",无法完成;选择 "ca" + "rs" 才能成功。
动态规划会同时保留所有可达前缀,不会因为一次局部选择而丢失正确答案。
16. 与完全背包的联系和区别
字典单词可以重复使用,因此本题有时被类比为“排列型完全背包”:
- 字符串长度类似背包容量。
- 字典单词类似可以重复选择的物品。
- 单词拼接顺序会影响能否匹配原字符串。
但本题不能只看单词长度。每次转移还必须检查对应位置的字符内容是否真的等于该单词,所以直接从“前缀可达性”理解通常更清楚。
17. 面试追问
如果需要返回一种合法拆分怎么办?
在把 dp[end] 设为 true 时,额外记录它来自哪个 start。计算结束后从 s.length 沿前驱位置反向回溯,即可恢复一种拆分方案。
如果需要返回所有合法句子怎么办?
这就是 LeetCode 140「单词拆分 II」。需要在搜索过程中记录所有可行选择,通常使用记忆化 DFS 返回每个后缀能够组成的所有句子。输出数量可能是指数级,不能只用布尔 DP 表示结果。
如果字典很大,如何继续优化?
可以使用字典树从每个可达位置开始沿字符串向后匹配,避免反复创建子串,也能在字符不匹配时尽早停止。代价是实现和额外数据结构更复杂。
18. 可迁移总结
本题最重要的思考方式是枚举最后一段:
遇到字符串分段问题时,可以依次思考:
dp[i]能否表示前i个字符的答案?- 最后一段可能从哪里开始?
- 前面的切分点是否可达?
- 当前片段是否满足题目条件?
这套“可达前缀 + 合法最后一段”的模型也适用于解码方法、回文串分割、句子切分和字符串拼接等问题。

