🚀 LeetCode 热题 139:单词拆分(Word Break)| 动态规划全解析+细节陷阱
📌 题目描述
给你一个字符串
s
和一个字符串列表wordDict
作为字典。请判断s
是否可以由字典中出现的单词拼接成。
说明:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
🎯 示例 1:
输入:s = "leetcode", wordDict = ["leet", "code"]
输出:true
解释:返回 true 因为 "leetcode" 可以被拆分为 "leet code"。
🎯 示例 2:
输入:s = "applepenapple", wordDict = ["apple", "pen"]
输出:true
解释:可以拼接成 "apple pen apple",可以重复使用 wordDict 中的单词。
🎯 示例 3:
输入:s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出:false
💡 解题思路一:动态规划(DP)
我们定义一个布尔类型的一维数组 dp[i]
表示 s[0:i]
这个子串是否可以由字典中的单词组成。
🧱 状态定义:
我们用一个布尔数组 dp[i]
表示:
从字符串的起始位置(下标 0)到位置 i 的子串 s[0:i] 是否可以被成功拆分成一个或多个字典中的单词。
注意:这里的
i
是长度,不是下标(下标是i-1
)。
✅ 状态转移方程:
dp[i] = true,若存在 j,使得 dp[j] == true 且 s[j:i] 在 wordDict 中
也就是说,如果前面某个位置 j
之前的子串可以被拼出,并且 s[j:i]
在字典中,那 dp[i]
就可以设置为 true
。
✅ 初始条件:
dp[0] = true // 空字符串可视为已完成
💻 Go 实现代码(动态规划)
func wordBreak(s string, wordDict []string) bool {
wordSet := make(map[string]bool)
for _, word := range wordDict {
wordSet[word] = true
}
dp := make([]bool, len(s)+1)
dp[0] = true
for i := 1; i <= len(s); i++ {
for j := 0; j < i; j++ {
if dp[j] && wordSet[s[j:i]] {
dp[i] = true
break
}
}
}
return dp[len(s)]
}
🔍 注意点 & 边界问题解析
✅ 注意 1:不要和子串的下标混淆
dp[i]
代表的是前i
个字符构成的子串(即s[0:i]
),而不是下标i
的字符。- 所以判断子串是否在字典中时,要写
s[j:i]
,而不是s[j:i+1]
。
✅ 注意 2:字典查找效率
使用 map[string]bool
构造一个哈希集合 wordSet
替代数组,可以让查找从 O(n) 降到 O(1),大幅提升效率:
wordSet := make(map[string]bool)
for _, word := range wordDict {
wordSet[word] = true
}
✅ 注意 3:剪枝优化(提前结束)
只要在内层循环找到一个合法切割位置,就可以直接 break
,节省无效循环。
✅ 注意 4:空字符串与空字典
s = ""
,返回true
,空字符串默认可以拆分(dp[0]=true)。wordDict = []
,返回false
,无单词可用无法拼出。
🌈 图解理解
假设输入:
s = "applepenapple"
wordDict = ["apple", "pen"]
我们依次维护 dp
状态:
i | 子串 s[0:i] | 能否拆分 | dp[i] |
---|---|---|---|
0 | “” | ✅ | true |
5 | “apple” | ✅ | true |
8 | “applepen” | ✅ | true |
13 | “applepenapple” | ✅ | true |
🧠 更深层的理解(背后的思想)
本题其实可以抽象为:
把一个字符串切成若干段,看这些段是否全都能在字典中找到。
也可以类比为:
一个人从字符串左端起跳,只能跳到在字典中出现的词结尾位置,问能否跳到终点。
所以动态规划是处理“前缀是否可达”这种问题的最优解。
⏳ 复杂度分析
类型 | 复杂度 |
---|---|
时间复杂度 | O(n²) |
空间复杂度 | O(n) |
n
是字符串长度。- 时间复杂度主要来自两层循环和切片操作。
- 使用哈希集合加速查找操作。
🧠 解题思路二:记忆化搜索(DFS + 记忆化)
从起始位置出发,尝试所有可能的切割位置,只要有一个可行就返回 true
。
为了防止重复计算,使用 map[int]bool
进行记忆。
💻 Go 实现代码(记忆化 DFS)
func wordBreak(s string, wordDict []string) bool {
wordSet := make(map[string]bool)
for _, word := range wordDict {
wordSet[word] = true
}
memo := make(map[int]bool)
var dfs func(int) bool
dfs = func(start int) bool {
if start == len(s) {
return true
}
if val, ok := memo[start]; ok {
return val
}
for end := start + 1; end <= len(s); end++ {
if wordSet[s[start:end]] && dfs(end) {
memo[start] = true
return true
}
}
memo[start] = false
return false
}
return dfs(0)
}
🔍 两种方法对比
方法 | 优点 | 缺点 |
---|---|---|
动态规划 | 性能稳定,逻辑清晰,适合面试高频 | 实现上可能略显冗长 |
记忆化 DFS | 更贴近人类思考方式,递归直观 | 有栈溢出风险,依赖剪枝优化 |
✅ 总结
- 本题是经典的字符串 + 动态规划题型。
- 动态规划和记忆化搜索都值得掌握!
- 实际编码中推荐使用动态规划,执行效率更高。
🎁 加分思考
- 如果要求输出所有可能的拆分方式?
- 如果字典非常大,如何优化查找?(使用 Trie 前缀树)
🌟 更多高频算法题持续更新中…
欢迎点赞 👍、收藏 ⭐、评论 💬、关注 🧠,支持我继续输出优质 LeetCode 题解!💻📘📌