1. 问题描述与理解
今天我们来深入探讨LeetCode热题100中的第139题——单词拆分问题。这个问题看似简单,但蕴含着动态规划的经典思想,是理解DP应用的绝佳案例。
题目要求我们判断一个字符串s能否由给定的字典wordDict中的单词拼接而成。字典中的单词可以重复使用,且不需要全部使用。举个例子:
- s = "leetcode", wordDict = ["leet", "code"] → 可以拆分为"leet"+"code",返回true
- s = "applepenapple", wordDict = ["apple", "pen"] → 可以拆分为"apple"+"pen"+"apple",返回true
- s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] → 无法拆分,返回false
2. 暴力解法与优化思路
2.1 暴力递归法
最直观的解法是使用递归回溯:
- 尝试用字典中的每个单词匹配字符串开头
- 如果匹配成功,对剩余部分递归调用
- 只要有一条路径能完全匹配就返回true
这种解法虽然直观,但时间复杂度高达O(2^n),因为存在大量重复计算。
2.2 引入记忆化
我们可以通过记忆化技术优化递归解法,将已经计算过的子问题结果存储起来,避免重复计算。这已经是一种自顶向下的动态规划思路。
2.3 完全动态规划解法
更高效的方式是采用自底向上的动态规划,这也是我们重点要讲解的解法。
3. 动态规划解法详解
3.1 DP数组定义
我们定义一个布尔型数组dp,其中:
- dp[i]表示字符串s的前i个字符(即s[0:i])能否被字典中的单词拆分
例如:
- dp[0] = True(空字符串可以被拆分)
- dp[n]就是我们最终要的结果(整个字符串能否被拆分)
3.2 状态转移方程
对于每个i(1 ≤ i ≤ n),我们检查所有可能的j(0 ≤ j < i):
- 如果dp[j]为True(前j个字符可以拆分)
- 且s[j:i]在字典中
那么dp[i]就可以设为True
用公式表示就是:
dp[i] = dp[j] && (s[j:i] in wordDict) for any j < i
3.3 算法实现步骤
- 将wordDict转换为集合word_set,提高查找效率
- 计算字典中单词的最大长度max_len,用于优化
- 初始化dp数组,长度为n+1,dp[0]=True
- 双重循环:
- 外层循环i从1到n
- 内层循环j从max(i-max_len,0)到i-1(优化点)
- 如果找到符合条件的j,设置dp[i]=True并跳出内层循环
- 返回dp[n]
3.4 代码实现
python复制class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
word_set = set(wordDict)
max_len = max(len(w) for w in wordDict) if wordDict else 0
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
# 优化:只检查可能存在的单词长度范围
start = max(0, i - max_len) if max_len > 0 else 0
for j in range(start, i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
break
return dp[n]
4. 关键点解析与优化
4.1 为什么dp[0]初始化为True?
这是一个边界条件的技巧。dp[0]表示空字符串可以被拆分,这样当整个字符串正好是字典中的一个单词时(j=0的情况),逻辑才能成立。
4.2 内层循环的优化
我们通过计算字典中单词的最大长度max_len,将内层循环的范围限制在[i-max_len, i-1]。这是因为:
- 如果j < i-max_len,那么s[j:i]的长度>max_len,肯定不在字典中
- 这样可以显著减少不必要的检查
4.3 时间复杂度分析
- 最坏情况下:O(n^2)
- 优化后:O(n * m),其中m是字典中最长单词的长度
- 空间复杂度:O(n)(dp数组)
5. 实例推演
让我们用示例1详细推演dp数组的填充过程:
s = "leetcode", wordDict = ["leet", "code"]
初始化:
dp = [T, F, F, F, F, F, F, F, F]
i=1: "l" → 无法拆分
i=2: "le" → 无法拆分
i=3: "lee" → 无法拆分
i=4: "leet" → dp[0] && "leet"在字典中 → dp[4]=T
i=5: "leetc" → 无法拆分
i=6: "leetco" → 无法拆分
i=7: "leetcod" → 无法拆分
i=8: "leetcode" → dp[4] && "code"在字典中 → dp[8]=T
最终返回dp[8]=True
6. 常见问题与调试技巧
6.1 常见错误
- 忘记将wordDict转为集合,导致查找效率低
- dp数组长度设为n而不是n+1
- 内层循环范围设置错误
- 没有处理空字典的情况
6.2 调试建议
- 打印dp数组的中间状态
- 对于小例子手动计算预期结果
- 检查边界条件(空字符串、单字符字符串等)
6.3 测试用例设计
好的测试用例应包括:
- 空字符串
- 字典为空
- 完全匹配的情况
- 需要多个单词拼接的情况
- 无法拆分的情况
- 包含重复单词的情况
例如:
- s="", wordDict=["a"] → True
- s="a", wordDict=[] → False
- s="aaaa", wordDict=["a"] → True
- s="abcd", wordDict=["a", "abc", "d"] → True
- s="abcd", wordDict=["ab", "cd", "abc"] → True
7. 扩展与变种
7.1 返回所有可能的拆分方式
如果需要返回所有可能的拆分组合,可以使用回溯+记忆化的方法。这需要维护一个字典记录每个位置可能的拆分方式。
7.2 字典中存在空字符串
如果字典可能包含空字符串,需要特殊处理,因为空字符串可以匹配任何位置。
7.3 超大字符串处理
对于非常长的字符串,可以考虑:
- 先检查字符串中所有字符是否都出现在字典中
- 分段处理
- 使用更高效的数据结构如Trie树
8. 实际应用场景
单词拆分问题在实际中有很多应用:
- 文本断字处理
- 密码破解中的字典攻击
- 自然语言处理中的分词
- 编译器中的词法分析
理解这个问题的解法有助于我们处理其他类似的字符串匹配和分割问题。动态规划的思想也可以推广到更复杂的场景,如正则表达式匹配、路径规划等。