LeetCode 139|单词拆分:从"把单词删掉"到用 DP 记录合法切口
- 一、题目到底在问什么?
- 二、最开始的想法:能不能不断把匹配到的单词去掉?
-
- [2.1 `split()` 一次会处理掉所有匹配位置](#2.1
split()一次会处理掉所有匹配位置) - [2.2 那如果一次只处理一个单词呢?](#2.2 那如果一次只处理一个单词呢?)
- [2.1 `split()` 一次会处理掉所有匹配位置](#2.1
- 三、为什么这里会想到递归?
-
- [3.1 一层递归到底在干什么?](#3.1 一层递归到底在干什么?)
-
- [第一层先尝试 `"car"`](#第一层先尝试
"car") - [第二层处理 `"s"`](#第二层处理
"s") - [False 返回第一层以后发生什么?](#False 返回第一层以后发生什么?)
- [新的第二层处理 `"rs"`](#新的第二层处理
"rs")
- [第一层先尝试 `"car"`](#第一层先尝试
- [3.2 那什么时候才会 return False?](#3.2 那什么时候才会 return False?)
- [3.3 完整代码](#3.3 完整代码)
- 四、递归虽然正确,但又出现了一个新问题
- 五、从记忆化递归,再走到动态规划
- [六、dpi 到底表示什么?](#六、dp[i] 到底表示什么?)
-
- [那 dp0 为什么是 True?](#那 dp[0] 为什么是 True?)
- [新的 dpi 要怎么算?](#新的 dp[i] 要怎么算?)
-
- [sj:i 到底是哪一段?](#s[j:i] 到底是哪一段?)
- [七、状态转移:前面已经能拆 + 后面是一个单词](#七、状态转移:前面已经能拆 + 后面是一个单词)
- [八、拿 cars 完整走一遍](#八、拿 cars 完整走一遍)
-
- [i = 1](#i = 1)
- [i = 2](#i = 2)
- [i = 3](#i = 3)
- [i = 4](#i = 4)
- [九、为什么所有 True 都要保留下来?](#九、为什么所有 True 都要保留下来?)
-
- [j 到底是什么?为什么要把所有 j 都试一遍?](#j 到底是什么?为什么要把所有 j 都试一遍?)
- 十、思路想清楚以后再看代码
- 十一、复杂度
一、题目到底在问什么?
题目给我们一个字符串 s,以及一个单词字典 wordDict。
我们需要判断:
能不能把整个字符串拆成若干个字典中存在的单词?
比如:
text
s = "leetcode"
wordDict = ["leet", "code"]
可以拆成:
text
leet | code
所以返回:
text
True
再比如:
text
s = "cars"
wordDict = ["ca", "car", "rs"]
虽然:
text
car | s
走不通,因为 "s" 不在字典里,
但还可以:
text
ca | rs
所以最终仍然应该返回:
text
True
这个例子其实也暴露出了这道题最麻烦的地方:
一个字符串可能有很多种切法,我们不能因为某一种切法失败,就直接认为整个字符串失败。
二、最开始的想法:能不能不断把匹配到的单词去掉?
我最开始想到的方法很直接:
从字典里拿一个单词,把字符串中能对应上的部分去掉,再继续用其他单词处理剩下的部分。
比如:
text
s = "lettercodeletter"
wordDict = ["letter", "code"]
当时我甚至想到可以直接用:
python
split()
比如先用 "letter":
python
"lettercodeletter".split("letter")
会得到:
text
["", "code", ""]
把空字符串去掉以后,就只剩:
text
["code"]
再用 "code" 继续处理,最后什么都不剩,就返回 True。
所以我最开始的想法大概是:
text
原字符串
↓
选择一个字典单词
↓
用 split 把匹配部分去掉
↓
继续处理剩余片段
↓
最后如果没有剩余
→ True
这个想法在一些简单例子里确实能跑通。
但是继续想以后,我发现会出问题, 原因就在 split() 本身。
2.1 split() 一次会处理掉所有匹配位置
比如:
text
s = "ababa"
wordDict = ["ab", "aba"]
这个字符串存在正确拆法:
text
ab | aba
也就是说,我们真正想做的是:
text
ababa
↓ 这一步只使用一次 "ab"
aba
↓ 再使用 "aba"
""
这样就成功了。
但如果直接写:
python
"ababa".split("ab")
得到的却是:
text
["", "", "a"]
因为 split("ab") 不会只帮我处理第一个 "ab"。
它相当于一次把能匹配到的 "ab" 都拿去切了:
text
ab | ab | a
于是原本应该留给下一步的:
text
aba
也被破坏掉了。
如果反过来先:
python
"ababa".split("aba")
又只会剩:
text
["", "ba"]
同样走不通。
所以:
这道题最好是一步只匹配一个单词,而
split()一次就替我确定了多个匹配位置。这会把后面本来还应该保留的选择提前处理掉。
2.2 那如果一次只处理一个单词呢?
想到这里,其实可以继续改:
text
不要一次 split 掉所有匹配
而是:这一步只选择一个单词
↓
只处理这一块
↓
剩余部分留给下一步
比如刚才的:
text
ababa
可以这样:
text
ab | aba
这一轮只选择:
text
ab
剩下:
text
aba
下一轮再选择:
text
aba
最后就会成功。
不过,这时又出现了另一个问题:
这一轮到底应该选哪个单词?
比如有多个单词都可以作为当前选择,那么:
text
先选 A
和:
text
先选 B
会产生不同的剩余状态。
而且每选完一个单词以后,下一步又会出现新的选择。
再加上题目规定:
text
不要求字典中的单词全部使用
并且同一个单词可以重复使用
所以如果自己把所有可能的使用顺序一个个列出来:
text
第一步选谁?
↓
第二步选谁?
↓
第三步选谁?
↓
这条路线失败以后,再换什么?
组合会越来越多。
所以我最后放弃了最开始直接用 split() 一路处理下去的办法。
但到这里,我反而发现:
既然每一步只需要做一个选择,而且这个选择失败以后还要回来尝试其他选择,那就可以把每种选择分别当成一条路线。
这就来到了第二个思路:递归。
三、为什么这里会想到递归?
前面把 split() 的问题想清楚以后,我实际上已经把思路改成了:
text
一次只选择一个单词
↓
处理这一块
↓
剩下的部分继续处理
但这里还有一个问题:
剩下的部分到底要怎么继续处理?
比如:
text
s = "cars"
wordDict = ["car", "ca", "rs"]
如果这一层选择:
text
ca
那么:
text
cars
↓
rs
接下来需要解决的就是:
text
"rs" 能不能继续被 wordDict 中的单词完整拆掉?
而一开始面对 "cars" 时,我问的其实也是:
text
"cars" 能不能被 wordDict 中的单词完整拆掉?
也就是说:
text
cars 能不能拆?
和:
text
rs 能不能拆?
其实是完全同一种问题。
只是字符串变短了。
所以这里就出现了递归最明显的特征:
做完一步以后,剩下的问题和原问题完全一样,只是规模更小。
于是可以定义:
text
dfs(current) = 当前剩余字符串 current 能不能被完整拆分?
当前这一层只负责:
text
尝试一个单词
如果这个单词可以使用,就得到一个更短的剩余字符串,再把同样的问题交给下一层:
text
dfs(剩余字符串)
比如:
text
dfs("cars")
↓
选择 "ca"
↓
dfs("rs")
↓
选择 "ca" → 不行, 换
↓
选择 "rs"
↓
dfs("")
↓
True
所以:
text
每一层做的事情都一样
+
做完一次选择以后
剩下的还是同一种问题
+
我又不知道到底需要重复多少层
这时候与其自己提前写很多层循环,不如让同一个函数不断处理剩余字符串。
3.1 一层递归到底在干什么?
这里最容易绕的地方,其实是:
这一层的
for循环,和下一层递归到底是什么关系?
还是看:
text
s = "cars"
wordDict = ["car", "ca", "rs"]
最开始调用:
text
dfs("cars")
这就是第一层。
第一层有自己的:
text
for word in wordDict
它会依次尝试:
text
"car"
"ca"
"rs"
第一层先尝试 "car"
发现:
text
cars
^^^
car
可以匹配。
于是剩下:
text
"s"
这时候第一层不会继续往后遍历 "ca"。
它会先停下来,调用:
text
dfs("s")
也就是进入第二层。
可以理解成:
text
第一层:dfs("cars")
尝试 "car"
↓
先等 dfs("s") 给我结果
第二层处理 "s"
第二层也有自己独立的一轮:
text
for word in wordDict
它同样从头开始尝试:
text
"car"
"ca"
"rs"
但它们都不能匹配:
text
"s"
所以第二层把所有单词都试完以后,只能:
text
return False
也就是说:
text
dfs("s") = False
False 返回第一层以后发生什么?
这时候程序重新回到:
text
dfs("cars")
刚才已经知道:
text
选择 "car" → 后面走不通
但第一层自己的 for 循环还没有结束。
所以它继续尝试下一个:
text
"ca"
发现:
text
cars
^^
ca
可以匹配。
于是剩下:
text
"rs"
第一层再次停下来,进入:
text
dfs("rs")
新的第二层处理 "rs"
这一层又重新遍历:
text
"car"
"ca"
"rs"
前两个都不能匹配。
走到:
text
"rs"
时成功:
text
rs
^^
rs
剩下:
text
""
于是继续进入:
text
dfs("")
这时候已经没有剩余字符,所以:
text
return True
于是:
text
dfs("") = True
这个 True 返回给:
text
dfs("rs")
所以:
text
dfs("rs") = True
然后又继续返回给:
text
dfs("cars")
所以最终:
text
dfs("cars") = True
整个过程实际上是:
text
dfs("cars") 第一层
│
├── 尝试 "car"
│ ↓
│ dfs("s") 第二层
│ ├── "car" 不匹配
│ ├── "ca" 不匹配
│ └── "rs" 不匹配
│ ↓
│ False
│
│ False 返回第一层
│
├── 第一层继续尝试 "ca"
│ ↓
│ dfs("rs") 第二层
│ ├── "car" 不匹配
│ ├── "ca" 不匹配
│ └── "rs" 匹配
│ ↓
│ dfs("") 第三层
│ ↓
│ True
│
└── True 一层层往上传
↓
True
所以递归并不是:
text
第一层选完一个词以后
第二层自动知道应该选择正确答案
它其实还是一个个试。
只不过:
每一层都有自己的一次
wordDict遍历。
某一层选择一个单词以后,会先进入下一层,看这条路线最终能不能成功。
如果下一层:
text
return False
就回来继续当前这一层的 for 循环,尝试下一个单词。
如果下一层:
text
return True
说明已经找到一条完整可行的路线,就不需要继续试了,直接把 True 一层层传回去。
3.2 那什么时候才会 return False?
这里也很重要。
比如当前是:
text
dfs("s")
这一层会尝试完整个:
text
wordDict
如果:
text
"car"
"ca"
"rs"
全部都无法让这条路线继续成功,
那么这一层才会:
text
return False
所以:
text
return False
代表的不是:
某一个单词失败了。
而是:
当前这一层所有可以尝试的单词全部都失败了。
只有这样,才能确认:
text
当前这个 current 确实无法被完整拆分
然后再把这个 False 返回上一层。
上一层收到 False 后,再继续尝试它自己的下一个选择。
如果最后连最开始那一层:
text
dfs(s)
所有选择都试完了,还是没有任何一条路线能够到达:
text
""
最终才会得到:
text
False
所以整个递归其实就是在不断尝试:
text
第一层选一个单词
↓
第二层再选一个单词
↓
第三层再选一个单词
↓
......
如果某条路线失败
↑
返回上一层
↑
换一个选择继续试
直到:
text
找到一条能够走到 "" → True
或者:
text
所有可能路线全部试过 → False
这就是这道题递归的运行方式。
3.3 完整代码
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
def dfs(current):
# 当前已经没有剩余字符串
# 说明这一整条路线成功了
if current == "":
return True
# 当前这一层,尝试 wordDict 中的每一个单词
for word in wordDict:
# 这个 word 能作为当前这一层选择的第一块
if current.startswith(word):
# 得到选择这个 word 以后剩下的部分
rest = current[len(word):]
# 进入下一层
# 当前层会先等下一层返回结果
if dfs(rest):
# 下一层最终能成功
# 说明当前选择也属于一条成功路线
return True
# 如果 dfs(rest) 返回 False
# 不会在这里 return
# 而是继续当前这一层的 for
# 尝试下一个 word
# 能执行到这里,说明:
# 当前这一层所有 word 都试过了
# 没有任何一条路线能够成功
return False
return dfs(s)
这里最主要是这一段:
python
for word in wordDict:
if current.startswith(word):
rest = current[len(word):]
if dfs(rest):
return True
return False
它的意思是:
text
尝试一个 word
↓
递归看看这条路线后面能不能成功
成功 → 直接 True
失败
→ 回到当前层
→ for 继续尝试下一个 word
所有 word 全部失败 → False
PS:
current.startswith(word) 是 Python 字符串自带的方法, 意思是:
current 是否以 word 开头?
只是判断,不会修改字符串。
比如:current = "cars"current.startswith("ca") → True
current.startswith("rs") → False
四、递归虽然正确,但又出现了一个新问题
到这里,递归已经可以把不同的选择路线全部尝试出来。
但是继续看,会发现一个新的问题:
不同路线可能会走到同一个剩余字符串,于是同一个问题被重复算了很多次。
例如:
text
s = "aaaa"
wordDict = ["a", "aa"]
可能出现:
text
dfs("aaaa")
├── 选 "a"
│ ↓
│ dfs("aaa")
│ ├── 选 "a"
│ │ ↓
│ │ dfs("aa")
│ │
│ └── ...
│
└── 选 "aa"
↓
dfs("aa")
这里:
text
dfs("aa")
就出现了不止一次。
但:
text
"aa" 能不能被完整拆分?
这个问题的答案并不会因为它从哪条路线过来而改变。
既然第一次已经算过:
text
dfs("aa") = True
下一次再遇到 "aa",其实就没有必要重新把整个 wordDict 再跑一次?
所以就可以想到:
把已经算过的 current 和答案用字典保存下来。
这就是记忆化。
完整代码:
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
memo = {}
def dfs(current):
if current == "":
return True
# 这个剩余字符串以前已经算过
if current in memo:
return memo[current]
for word in wordDict:
if current.startswith(word):
rest = current[len(word):]
if dfs(rest):
memo[current] = True
return True
# 所有路线都失败以后
# 才能确定 current = False
memo[current] = False
return False
return dfs(s)
这里有一个容易混淆的地方:
python
if dfs(rest):
memo[current] = True
return True
dfs(rest) 判断的是:
text
剩余字符串 rest 能不能被拆完
而:
python
memo[current] = True
记录的是:
text
当前字符串 current 能不能被拆完
比如:
text
current = "cars"
word = "ca"
rest = "rs"
如果:
text
dfs("rs") = True
说明 "rs" 能继续拆完。
那么:
text
"cars" = "ca" + "rs"
也就说明 "cars" 同样可以拆完。
所以要保存:
python
memo["cars"] = True
这里的 memo[current] = True 不是为了让这次递归成功,而是为了以后如果再次遇到同一个 current,可以直接使用之前的结果,不需要重新计算。
可以简单记成:
text
dfs(rest) → 问下一层的结果
memo[current] = True → 记住当前这一层的结果
return True → 把当前这一层的结果传回上一层
五、从记忆化递归,再走到动态规划
前面的递归里,我们已经开始保存重复出现的子问题。
如果用位置来表示递归状态,可以理解成:
text
dfs(i) = 从位置 i 开始,后面的字符串能不能被完整拆分?
比如:
text
s = "cars"
可能会问:
text
dfs(0) → "cars" 能不能拆?
dfs(2) → "rs" 能不能拆?
dfs(4) → "" 能不能拆?
加上 memo 以后,本质上就是:
text
这个位置以前算过 → 直接使用之前的 True / False
这已经和动态规划非常接近了,因为它们都有一个共同点:
把已经解决过的子问题保存下来,后面直接复用。
之前学动态规划的时候也是这样。
比如爬楼梯保存的是:
text
到第 i 阶一共有多少种走法
打家劫舍保存的是:
text
前 i 间房最多能拿多少钱
所以来到这道题,我开始想:
既然我本来就在保存"某个位置能不能成功",那能不能干脆把这些位置的结果直接放进一个数组里,一步一步算出来?
只不过这一次,我换了一个更容易从左往右推的角度:
与其不断问"从当前位置开始,后面还能不能走到结尾",
不如从字符串开头开始记录:"目前哪些位置已经可以合法到达?"
于是状态变成:
text
dp[i] = 前 i 个字符能不能被完整拆分
比如:
text
s = "cars"
我们不再保存:
text
"cars" 能不能拆?
"rs" 能不能拆?
"s" 能不能拆?
而是依次保存:
text
前 0 个字符能不能拆?
前 1 个字符能不能拆?
前 2 个字符能不能拆?
前 3 个字符能不能拆?
前 4 个字符能不能拆?
也就是:
text
dp[0]
dp[1]
dp[2]
dp[3]
dp[4]
所以可以把两种方法简单理解成:
text
递归 / 记忆化: 站在当前位置,问后面能不能走到终点
DP: 从起点开始,记录哪些位置已经能够到达
它们解决的是同一个问题,只是观察方向不同。
六、dpi 到底表示什么?
现在正式定义:
text
dp[i] = 前 i 个字符能不能被完整地拆成字典中的单词
这里很容易绕的一点是:
i更适合理解成"切口",而不是某个字符的下标。
比如:
text
s = "cars"
字符下标是:
text
字符: c a r s
下标: 0 1 2 3
但是如果从切字符串的角度看,其实有 5 个位置:
text
切口: 0 1 2 3 4
| c | a | r | s |
所以:
text
dp[0]
→ 前 0 个字符
→ ""
dp[1]
→ "c"
dp[2]
→ "ca"
dp[3]
→ "car"
dp[4]
→ "cars"
字符串长度虽然只有:
text
4
但我们需要记录:
text
0 ~ 4
这 5 个切口。
所以, 创建保存的列表:
python
dp = [False] * (n + 1)
这里的 n + 1,其实就是因为:
我们记录的是切到哪里,而不是某一个字符本身。
那 dp0 为什么是 True?
dp[0] 问的是:
前 0 个字符,也就是空字符串
"",能不能认为已经合法拆完?
所以, 答案需要设成:
python
dp[0] = True
因为它是整个 DP 的起点。
比如:
text
s = "ca"
如果:
text
"ca" in wordDict
我们希望能够得到:
text
"" | "ca"
前面的空字符串已经是一个合法状态:
text
dp[0] = True
后面的 "ca" 又是字典单词。
所以:
text
dp[2] = True
新的 dpi 要怎么算?
现在已经知道:
text
dp[i] = 前 i 个字符能不能合法拆分
接下来真正的问题就是:
我怎么判断一个新的
dp[i]?
比如:
text
s = "cars"
现在我要判断:
text
dp[4]
也就是:
text
"cars"
能不能拆。
这时候我并不知道最后一个单词从哪里开始。
它可能切成:
text
"" | "cars"
"c" | "ars"
"ca" | "rs"
"car" | "s"
所以需要另一个位置:
text
j
让它表示:
最后一个单词准备从哪里开始。
画出来就是:
text
0 ........ j ........ i
前 i 个字符被分成:
text
s[:j] | s[j:i]
其中:
text
s[:j]
是前面已经处理过的部分。
而:
text
s[j:i]
是我现在准备接上的最后一个单词。
sj:i 到底是哪一段?
🚩 Python 切片是:
左闭右开。
所以:
python
s[j:i]
可以直接理解成:
切口 j 到切口 i 中间的这一段。
还是:
text
s = "cars"
切口: 0 1 2 3 4
| c | a | r | s |
那么:
python
s[0:2]
就是:
text
"ca"
而:
python
s[2:4]
就是:
text
"rs"
七、状态转移:前面已经能拆 + 后面是一个单词
现在:
text
0 ........ j ........ i
已经被分成:
text
前面的部分 | 最后一个单词
那想让:
text
dp[i] = True
其实只需要满足两个条件。
第一:
text
前面的部分已经可以完整拆分
这个答案我们之前已经保存过:
python
dp[j]
第二:
text
最后这一段本身就是一个字典单词
也就是:
python
s[j:i] in wordDict
所以只要存在某个 j:
text
dp[j] == True
并且:
text
s[j:i] in wordDict
就可以得到:
text
dp[i] = True
画出来就是:
text
0 .......... j .......... i
| 已经能拆完 | 一个单词 |
↑ ↑
dp[j] s[j:i]
True in wordDict
↓
dp[i] = True
所以整个状态转移其实就是一句话:
如果我能够合法走到切口 j,并且从 j 到 i 又正好是一个字典单词,那么我就能够继续走到 i。
八、拿 cars 完整走一遍
现在用:
text
s = "cars"
wordDict = ["ca", "car", "rs"]
一开始:
text
dp = [True, False, False, False, False]
也就是目前只确定:
text
切口 0 可以合法到达
i = 1
现在判断:
text
"c"
只有:
text
"" | "c"
0 的 答案我们之前已经保存过, 且当前 j 为:
text
j = 0
dp[0] = True
但是:
text
"c" not in wordDict
所以:
text
dp[1] = False
目前:
text
dp = [True, False, False, False, False]
i = 2
现在判断:
text
"ca"
然后当前 j 依旧还没动
text
j = 0
"" | "ca"
因为:
text
dp[0] = True
而且:
text
"ca" in wordDict
所以:
text
dp[2] = True
变成:
text
dp = [True, False, True, False, False]
也就是说:
切口 2 已经被证明是一个合法位置。
i = 3
现在判断:
text
"car"
同样:
text
"" | "car"
满足:
text
dp[0] = True
并且:
text
"car" in wordDict
所以:
text
dp[3] = True
现在:
text
dp = [True, False, True, True, False]
这里有一个很重要的地方:
text
dp[2] = True
dp[3] = True
它们会同时保留下来。
不是:
text
发现 3 也能到 → 就把 2 替换掉
而是:
text
切口 2 可以合法到达
切口 3 也可以合法到达
因为后面到底需要从哪个位置继续接,现在还不知道。
i = 4
现在终于判断:
text
"cars"
如果看:
text
j = 3
那么:
text
car | s
虽然:
text
dp[3] = True
但是:
text
"s" not in wordDict
所以这条路失败。
但我们不能因此说:
text
dp[4] = False
因为还有其他以前合法的位置。
继续看:
text
j = 2
得到:
text
ca | rs
这时候:
text
dp[2] = True
并且:
text
"rs" in wordDict
所以:
text
dp[4] = True
最终:
text
dp = [True, False, True, True, True]
所以整个:
text
"cars"
可以合法拆分。
九、为什么所有 True 都要保留下来?
在刚才的例子:
text
dp = [True, False, True, True, False]
2 和:3 都是合法切口。
如果只记"最新的" 3
那么最后只能尝试:
text
car | s
然后得到失败。
但是 2 其实还能形成:
text
ca | rs
并且成功。
所以 DP 保存的不是:
当前最靠后的合法位置。
而是:
所有已经证明可以合法到达的位置。
后面任何一个位置,都有可能重新利用它们。
j 到底是什么?为什么要把所有 j 都试一遍?
现在我们已经知道:
text
dp[i] = 前 i 个字符能不能被完整拆分
那要判断新的:
text
dp[i]
其实最关键的问题就是:
最后一个单词到底从哪里开始?
这个开始位置就是:
text
j
所以:
text
0 ........ j ........ i
可以理解成:
text
前面已经处理好的部分 | 最后一个单词
也就是:
text
s[:j] | s[j:i]
其中:
text
dp[j]
表示:
前
j个字符已经能够被完整拆分。
而:
text
s[j:i]
表示:
从切口
j到切口i之间,准备作为最后一个单词的这一段。
所以只要:
text
dp[j] == True
并且:
text
s[j:i] in wordDict
就可以确定:
text
dp[i] = True
这里可能会有一个疑问:
题目明明可以拆成很多个单词,为什么这里只看
j,好像只把字符串切成了两边?
其实这里的两边并不是:
text
一个单词 | 一个单词
而是:
text
前面已经拆好的所有单词 | 最后一个单词
比如:
text
s = "applepenapple"
真正的拆法是:
text
apple | pen | apple
当判断整个字符串时,只需要看最后一刀:
text
applepen | apple
↑
j
右边:
text
"apple"
是最后一个单词。
而左边:
text
"applepen"
虽然现在看起来是一整块,但:
text
dp[j] = True
已经表示它之前被证明可以拆成:
text
apple | pen
所以:
text
dp[j] = True
+
s[j:i] = "apple" 在字典中
实际上代表的是:
text
apple | pen | apple
也就是说:
DP 并不是只把字符串拆成两块,而是把前面已经成功的所有拆分都压缩进了
dp[j]。现在只需要再确认最后一个单词。
那为什么每一个新的 i 都要重新遍历:
text
j = 0, 1, 2, ...
因为我们根本不知道:
最后一个单词到底应该从哪个位置开始。
比如:
text
s = "cars"
判断:
text
dp[4]
时,最后一刀可能切在:
text
"" | "cars" → j = 0
"c" | "ars" → j = 1
"ca" | "rs" → j = 2
"car" | "s" → j = 3
所以只能把这些可能的 j 一个个试过去。
不过这里的:
text
重新遍历 j
不等于:
text
重新计算前面的答案
比如已经知道:
text
dp[2] = True
以后再次遇到:
text
j = 2
我们不会重新判断:
text
"ca" 为什么能拆?
而是直接读取:
python
dp[2]
所以:
遍历 j 只是为了寻找"最后一个单词从哪里开始",前面的子问题已经保存在 dp 里了。
最后,还有一个问题:
为什么找到一个成功的
j以后,就可以直接停?
因为题目只问:
text
能不能拆?
并不要求:
text
一共有多少种拆法?
没必要继续寻找其他切法。
因此,关于 j 可以简单记成:
text
j 是什么?→ 最后一个单词从哪里开始
为什么遍历所有 j?→ 因为不知道最后一刀应该切在哪里
为什么只看 j 左右两边?→ 左边 dp[j] 已经包含了前面所有成功拆分
为什么找到一个成功 j 就停? → 因为题目只要求存在一种合法拆法
所以这一题里:
j的作用,就是不断尝试"最后一个单词从哪里开始"。
十、思路想清楚以后再看代码
到这里,代码基本只是把前面的逻辑翻译出来:
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n = len(s)
words = set(wordDict)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
return dp[n]
现在逐句看。
python
dp = [False] * (n + 1)
因为我们记录的是:
text
0 ~ n
这些切口。
python
dp[0] = True
因为空字符串是整个过程的合法起点。
python
for i in range(1, n + 1):
依次判断:
text
前 1 个字符能不能拆
前 2 个字符能不能拆
...
前 n 个字符能不能拆
python
for j in range(i):
因为不知道最后一个单词从哪里开始,
所以尝试:
text
0 ~ i - 1
之间所有可能的最后切口。
python
if dp[j] and s[j:i] in words:
就是检查:
text
前 j 个字符已经可以完整拆分
并且
j 到 i 这一段是一个字典单词
两者成立:
python
dp[i] = True
然后:
python
break
因为已经找到一种成功拆法。
最后:
python
return dp[n]
因为:
text
dp[n]
表示:
整个字符串的前 n 个字符,也就是整个字符串,能不能完整拆分。
十一、复杂度
字符串长度为:
text
n
外层遍历:
text
i = 1 ~ n
对于每一个 i,内层最多尝试:
text
j = 0 ~ i - 1
所以切口尝试次数大致是:
text
1 + 2 + 3 + ... + n
数量级为:
text
O(n²)
dp 数组有:
text
n + 1
个状态,因此 DP 数组本身的空间复杂度是:
text
O(n)
另外,在 Python 中:
python
s[j:i]
会创建新的子字符串,所以实际运行时间还会包含字符串切片的成本。
这里先把这道题最核心的 DP 结构理解成:
text
O(n²) 次切口尝试
就可以。
另外:
python
words = set(wordDict)
是因为后面会频繁判断:
python
s[j:i] in words
使用 set 更适合这种大量的成员查询。