DAY17: LeetCode 139|单词拆分:从“把单词删掉”到用 DP 记录合法切口

LeetCode 139|单词拆分:从"把单词删掉"到用 DP 记录合法切口

  • 一、题目到底在问什么?
  • 二、最开始的想法:能不能不断把匹配到的单词去掉?
    • [2.1 `split()` 一次会处理掉所有匹配位置](#2.1 split() 一次会处理掉所有匹配位置)
    • [2.2 那如果一次只处理一个单词呢?](#2.2 那如果一次只处理一个单词呢?)
  • 三、为什么这里会想到递归?
    • [3.1 一层递归到底在干什么?](#3.1 一层递归到底在干什么?)
      • [第一层先尝试 `"car"`](#第一层先尝试 "car")
      • [第二层处理 `"s"`](#第二层处理 "s")
      • [False 返回第一层以后发生什么?](#False 返回第一层以后发生什么?)
      • [新的第二层处理 `"rs"`](#新的第二层处理 "rs")
    • [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 更适合这种大量的成员查询。

相关推荐
猎头南楼1 小时前
企业网络安全体系与AI安全检测实践:零信任、纵深防御与LLM安全
人工智能·安全·web安全
科技重器1 小时前
京东方中央研究院推出高灵敏度电化学生物传感器芯片,为疾病早筛提供“芯”守护
人工智能·物联网
马剑威(威哥爱编程)1 小时前
【AI全栈后端12-03】Spring Boot 用多模型路由把智能客服成本降下来
java·人工智能·spring boot
Java的搬运工1 小时前
【无标题】
python·机器学习·docker·fastapi·模型部署·mlops·ai工程化
云计算-Security1 小时前
AI 赋能运维:UniRack 主机纳管平台的架构与实践
运维·人工智能·架构
霸道流氓气质1 小时前
AI模型幻觉检测与抑制完全指南:从规则引擎到RAG对比的Java生产级实战
java·开发语言·人工智能
weixin_404551241 小时前
使用 ZCode 改造 PPT 模板:实践复盘与能力边界
人工智能·powerpoint
陈希瑞1 小时前
LongCat-2.5-Preview 的web逆向能力实测
人工智能·算法·wasm
曲鸟1 小时前
体验完鸿蒙AI后的几点感受
人工智能·华为·harmonyos