一、完整代码(精简注释)
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
// 哈希集合存储字典中的单词
unordered_set<string> words;
for(auto x : wordDict){
words.insert(x);
}
// dp[i]表示前i个字符能否被成功拆分
vector<bool> dp(s.size()+1, false);
dp[0] = true; // 空字符串可以拆分
for(int i=1; i<=s.size(); i++){
for(int j=0; j<i; j++){
// 前半部分可拆分,且后半部分在字典中
if(dp[j]==true && words.count(s.substr(j,i-j))==true){
dp[i] = true;
break; // 找到合法拆分,退出内层循环
}
}
}
return dp[s.size()]; // 整个字符串能否拆分
}
};
二、动态规划五步总结
1. 确定 dp 数组含义
vector<bool> dp(s.size()+1, false);
dp[i] 表示字符串 s 的前 i 个字符能否由字典中的单词拼接得到。
例如:
s = "leetcode";
wordDict = {"leet", "code"};
| 状态 | 对应字符串 | 结果 |
|---|---|---|
dp[0] |
"" |
true |
dp[1] |
"l" |
false |
dp[2] |
"le" |
false |
dp[3] |
"lee" |
false |
dp[4] |
"leet" |
true |
dp[5] |
"leetc" |
false |
dp[8] |
"leetcode" |
true |
最终目标是求 dp[s.size()]。
2. 确定状态转移方程(核心)
if(dp[j]==true && words.count(s.substr(j,i-j))==true){
dp[i] = true;
break;
}
核心思想:将前 i 个字符在位置 j 处分成两部分。
例如:i = 8,j = 4
l
0
e
1
e
2
t
3
c
4
o
5
d
6
e
7
前半部分:leet
dp[j]
判断是否已经可以拆分
后半部分:code
s.substr(j,i-j)
判断是否在字典中
条件一:
dp[j] == true
表示前 j 个字符已经可以由字典中的单词拼接得到。
条件二:
words.count(s.substr(j,i-j)) == true
表示从下标 j 到 i-1 的字符串是字典中的一个完整单词。
注意:
s.substr(j,i-j)
-
j:截取的起始下标。 -
i-j:截取的字符数量。 -
截取范围:
[j,i)。
两个条件同时满足,就有:
dp[i] = true;
因为只需要找到一种合法拆分方式,所以可以直接 break,跳出内层 j 循环。
3. 初始化 dp 数组
vector<bool> dp(s.size()+1, false);
dp[0] = true;
-
s.size()+1:存储从dp[0]到dp[s.size()]的状态。 -
false:默认当前前缀不能成功拆分。 -
dp[0]=true:空字符串可以看作由零个单词拼接得到。
为什么 dp[0] 必须是 true?
假设:
s = "leetcode";
当 i=4,j=0 时:
dp[0] == true
s.substr(0,4) == "leet"
由于 "leet" 在字典中,所以:
dp[4] = true;
如果 dp[0]=false,就无法通过这个转移识别第一个完整单词。
4. 确定遍历顺序
for(int i=1; i<=s.size(); i++){
for(int j=0; j<i; j++){
if(dp[j]==true && words.count(s.substr(j,i-j))==true){
dp[i] = true;
break;
}
}
}
外层循环 i:枚举当前需要判断的字符串前缀长度。
例如:
i=1 → "l"
i=2 → "le"
i=3 → "lee"
i=4 → "leet"
...
i=8 → "leetcode"
内层循环 j:枚举当前前缀的分割位置。
例如,当 i=8 时:
| j | 前半部分 | 后半部分 s.substr(j,i-j) |
|---|---|---|
| 0 | "" |
"leetcode" |
| 1 | "l" |
"eetcode" |
| 2 | "le" |
"etcode" |
| 3 | "lee" |
"tcode" |
| 4 | "leet" |
"code" |
| 5 | "leetc" |
"ode" |
| 6 | "leetco" |
"de" |
| 7 | "leetcod" |
"e" |
当 j=4 时:
dp[4] == true
words.count("code") == 1
因此:
dp[8] = true;
break;
这时退出内层循环,继续执行下一个外层 i;如果 i 已经是 s.size(),则循环结束。
5. 确定返回值
return dp[s.size()];
-
true:整个字符串可以由字典中的单词拼接得到。 -
false:不存在合法的完整拆分方案。
三、复杂度分析
设 n 为字符串长度。
-
时间复杂度: 最坏 \(O(n^3)\)。双层循环枚举
i和j,substr()构造子串及哈希查找还可能花费 \(O(n)\)。 -
空间复杂度: DP 数组 \(O(n)\),临时子串最多 \(O(n)\),此外还需要存储字典的哈希集合。
四、核心思路总结
这道题属于布尔型动态规划,本质是判断是否存在一种合法的字符串拆分方案。
整个过程可以理解为:
-
使用
unordered_set存储字典中的单词,方便快速查找。 -
定义
dp[i],表示前i个字符能否成功拆分。 -
枚举当前前缀长度
i,再枚举分割位置j。 -
如果
dp[j]为true,并且[j,i)这一段字符串在字典中,那么dp[i]=true。 -
找到一种合法方案后,直接退出内层循环。
-
最后返回
dp[s.size()]。
最重要的状态转移逻辑:
\ \\boxed{ dp\[j=true\ \land\ sj:i\in wordDict \ \Longrightarrow\ dpi=true } \]
记住一句话:
i 决定当前判断前多少个字符,j 决定从哪里切开;dp[j] 判断左边,substr(j,i-j) 判断右边,两边都合法,dp[i] 就为 true。