Files

336 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [算法/动态规划, 字符串DP, 哈希表优化]
create time: 2026-05-16 19:30
---
# 139. 单词拆分(Word Break)
> [!quote] LeetCode 原题 · 中等
> 给你一个字符串 `s` 和一个字符串列表 `wordDict` 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 `s`,则返回 `true`。
>
> **不要求**字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
>
> - 输入范围:`1 <= s.length <= 300`,`1 <= wordDict.length <= 1000`,`1 <= wordDict[i].length <= 20`
> - `s` 和 `wordDict[i]` 仅由小写英文字母组成
> - `wordDict` 中的所有字符串互不相同
## 题面
**示例 1:**
```
输入:s = "leetcode", wordDict = ["leet", "code"]
输出:true
解释:"leetcode" 可以由 "leet" 和 "code" 拼接成。
```
**示例 2:**
```
输入:s = "applepenapple", wordDict = ["apple", "pen"]
输出:true
解释:"applepenapple" 可以由 "apple" "pen" "apple" 拼接成(单词可重复使用)。
```
**示例 3:**
```
输入:s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出:false
解释:虽然 "cats"、"dog" 等都能匹配到部分子串,但无法完整拼出整个字符串。
```
## 思路
### 第一步:识别问题模型
> [!question] 💡 思考
> 把字符串从某个位置切开——左边能用字典拼出来,右边恰好也是一个字典里的词——整段就能拼出来。这种"大问题拆成更小的同类子问题"的结构,指向哪类经典算法模型?
没错,这是 **线性 DP + 枚举分割点** 的经典范式。核心直觉:
> 要判断前 i 个字符能否被拆分,我们只需枚举最后一个词的起始位置 j —— 如果前 j 个字符能拆,且第 j+1 到第 i 个字符组成的子串在字典里,那么前 i 个字符也能拆。
### 第二步:定义状态与转移方程
定义状态:
> `dp[i]` = 字符串 `s` 的前 i 个字符能否用字典中的词拼接(布尔值)
状态转移:
> **`dp[i] = OR(dp[j] && s[j..i-1] ∈ wordDict)`**,对所有 `0 ≤ j < i`
其中 `s[j..i-1]` 表示从索引 j 开始、长度为 i-j 的子串。
```mermaid
flowchart TD
subgraph "dp[8] 的拆解过程 s = 'leetcode'"
A["dp[8]\n='leetcode'是否能拆分?"] --> B["j=0:\ndp[0]&&s[0..7]='leetcode'\nT && 不在字典 → ✗"]
A --> C["j=4:\ndp[4]&&s[4..7]='code'\nT && 在字典 ✓"]
A --> D["j=其他位置\n均无法同时满足两个条件\n→ ✗"]
B --> E["dp[8]=✗暂不成立"]
C --> F["dp[8]=✓\n找到一种可行拆分!\nleet + code"]
D --> G["继续检查..."]
F --> H["结果为 true ✅"]
end
style A fill:#e7f3ff,stroke:#3b82f6
style C fill:#dcfce7,stroke:#16a34a
style F fill:#dcfce7,stroke:#16a34a
style H fill:#dcfce7,stroke:#16a34a
```
> [!note] 📝 为什么这样是正确的?
> 因为每个合法拆分都可以唯一地看作"前面若干个词 + 最后一个词"的结构。我们枚举的就是这个"最后一个词"的起始位置。只要存在一种枚举使得两边都满足条件,答案就是 true。这体现了最优子结构性质——即使这里是布尔值而非数值。
### 第三步:确定边界条件
| 情况 | dp 值 | 理由 |
|------|-------|------|
| `dp[0]` | **true** | 空字符串显然可以被拆分——它是所有状态的"起点"。没有它为 false,递推就无法启动。 |
| 其他 `dp[i]` | **false** | 初始化为不可达,只有在找到有效拆分时才翻转为 true。 |
> [!warning] ⚠️ 关键细节:dp[0] = true
> 很多初学者会把 dp[0] 设为 false,这是致命错误。考虑 s = "leet", wordDict = ["leet"]——如果 dp[0] = false,那么 dp[4] 永远无法变为 true,因为唯一合法的拆分就是 dp[0] && "leet"。
### 第四步:自底向上填表
以 `s = "applepenapple", wordDict = ["apple", "pen"]` 为例,逐步走一遍:
| i | 子串 s[0..i) | 可能的 j | 判定过程 | dp[i] |
|---|-------------|---------|---------|-------|
| 0 | "" | — | 边界 | **true** |
| 1 | "a" | j=0 | dp[0] && "a" → T && ✗ | **false** |
| 2 | "ap" | j=0,1 | 均无匹配 | **false** |
| 3 | "app" | j=0,1,2 | 均无匹配 | **false** |
| 4 | "appl" | j=0,1,2,3 | 均无匹配 | **false** |
| 5 | "apple" | j=0…4 | j=0: dp[0]&&"apple" → **T && ✓** | **true** ← 第一个匹配的完整词! |
| 6 | "applep" | j=0…5 | j=5: dp[5]&&"p" → T && ✗ | **false** |
| 7 | "applepe" | j=0…6 | j=5: dp[5]&&"pe" → T && ✗ | **false** |
| 8 | "applepen" | j=0…7 | j=5: dp[5]&&"pen" → **T && ✓** | **true** ← apple + pen |
| 9 | "applepena" | j=0…8 | 均无新增匹配 | **false** |
| 10 | "applepenap" | j=0…9 | 均无新增匹配 | **false** |
| 11 | "applepenapp" | j=0…10 | 均无新增匹配 | **false** |
| 12 | "applepenappl" | j=0…11 | 均无新增匹配 | **false** |
| 13 | "applepenapple" | j=0…12 | j=8: dp[8]&&"apple" → **T && ✓** | **true** ← apple + pen + apple |
```mermaid
flowchart LR
A["dp[0]=true\n''"] -->|"加 apple"| B["dp[5]=true\n'apple'"]
B -->|"加 pen"| C["dp[8]=true\n'applepen'"]
C -->|"加 apple"| D["dp[13]=true\n'applepenapple' ★答案"]
style A fill:#f3f4f6,stroke:#6b7280
style B fill:#fef3c7,stroke:#f59e0b
style C fill:#fef3c7,stroke:#f59e0b
style D fill:#dcfce7,stroke:#16a34a
```
> 表中展示了**成功路径上的关键节点**。实际上在计算每个 dp[i] 时,算法会尝试所有 j 值,而不仅仅是这条成功路径上的跳跃点。
## 代码提示
在动手写代码之前,想一想这些关键决策点:
> [!question] 💡 思考 1
> 内层循环需要遍历所有 j < i,每次都要判断 `s[j..i-1]` 是否在字典中。Go 语言中怎么快速判断一个子串是否存在于集合中?
可以用 `map[string]bool` 将字典构建成 O(1) 查找的哈希集合。然后用 Go 的切片操作 `s[j:i]` 提取子串并直接 map 查询。
> [!question] 💡 思考 2
> 外层遍历 i 和内层遍历 j 的顺序能调换吗?先枚举 j 再枚举 i 是否仍然正确?
不行。因为计算 `dp[i]` 时需要依赖所有 `dp[j]`(j < i)的结果,必须保证子问题的结果已被计算完毕。所以外层必须是 i,内层是 j。这也决定了这是一个**自底向上**的填表顺序。
## 技巧
### 提前终止优化
> [!tip] ⚡ 小优化:一旦 dp[n] 为 true 就可以提前退出
>
> 如果在填表过程中发现 `dp[s.Len()] == true`,说明已经找到了可行解,可以直接返回,无需继续计算后续无关状态。这在大部分可解案例中能节省不少时间。
### 最大词长剪枝
> [!tip] ⚡ 进阶优化:j 的范围可以限制在 `[i-maxLen, i)` 之间
>
> 字典中最长单词的长度是 maxLen。任何超过 maxLen 的子串都不可能在字典中找到。因此 j 不必从 0 开始枚举,而是从 `max(0, i-maxLen)` 开始即可。
>
> 结合题目约束 `maxLen ≤ 20`,内层循环最多只遍历 20 次,而不是 i 次。对于长字符串效果显著。
### Trie(前缀树)视角
> [!abstract] 🔍 进阶视角:从后往前用 Trie 加速
>
> 另一种思路是从右往左用 Trie 匹配:对每个 i,沿着 Trie 从 s[i] 开始往下走,每到一个标记为"单词结尾"的节点 k,就检查 `dp[k]` 是否为 true。这样可以跳过大量无效的子串比对。
>
> 空间代价更高(需要构建 Trie),但在字典很大、字符串很长的场景下优势明显。面试中推荐先用哈希表方案,如需优化再提 Trie。
| 方法 | 时间 | 空间 | 评价 |
|------|------|------|------|
| 哈希表 + DP(推荐) | O(n² · L) | O(n + Σ|w|) | 简洁直观,面试首选,L 为子串比较成本 |
| Trie + DP | O(n · maxLen) | O(n + 字典总字符数) | 适合大数据量场景 |
> 注:O(n² · L) 中的 L 来自 Go 中 `s[j:i]` 创建新字符串的成本,平均约等于子串长度的一半。实际评测中 n ≤ 300,完全轻松通过。
## 代码
### Go 语言实现
#### 版本一:哈希表 + DP(推荐 ✅)
```go
// wordBreak determines if s can be segmented into a space-separated sequence
// of one or more dictionary words.
func wordBreak(s string, wordDict []string) bool {
// 将字典转为哈希集合,实现 O(1) 子串查找
ws := make(map[string]bool)
for _, w := range wordDict {
ws[w] = true
}
n := len(s)
// dp[i] = s[:i] 能否被字典中的词拼接
dp := make([]bool, n+1)
// 边界:空字符串可以被拆分
dp[0] = true
// 自底向上填表
for i := 1; i <= n; i++ {
for j := 0; j < i; j++ {
// 如果前 j 个字符可以拆分,且 s[j:i] 在字典中
if dp[j] && ws[s[j:i]] {
dp[i] = true
break // 找到一个合法拆分就够了
}
}
}
return dp[n]
}
```
> [!summary] 💡 代码结构拆解
> - **建哈希集合**:O(Σ|w|),把数组字典转为 O(1) 查找的 map,这一步是整个优化的基石
> - **外层 for i**:从左到右依次计算 dp[1] 到 dp[n],确保用到 dp[j] 时它已经被算好了
> - **内层 for j**:枚举最后一个词的分割点。`dp[j] && ws[s[j:i]]` 双条件:前半截已拆好 + 后半截是合法词汇
> - **break**:一旦发现一个合法的 j 就让 dp[i]=true 并跳出内层循环——不需要枚举更多了
#### 版本二:带最大词长剪枝
```go
// wordBreakWithPruning is an optimized version using the maximum word length
// to reduce the inner loop range.
func wordBreakWithPruning(s string, wordDict []string) bool {
ws := make(map[string]bool)
maxLen := 0
for _, w := range wordDict {
ws[w] = true
if len(w) > maxLen {
maxLen = len(w)
}
}
n := len(s)
dp := make([]bool, n+1)
dp[0] = true
for i := 1; i <= n; i++ {
// j 的下限:子串 s[j:i] 的长度不能超过字典中最长单词
start := i - maxLen
if start < 0 {
start = 0
}
for j := start; j < i; j++ {
if dp[j] && ws[s[j:i]] {
dp[i] = true
break
}
}
}
return dp[n]
}
```
> [!summary] 💡 两种版本的差异
> - 版本二的内层循环最多只遍历 maxLen(≤20)次,而非全部 i 次。当 n=300 且 maxLen=20 时,内层次数从 300 降到 20,性能提升约 15 倍
> - 代码略微复杂,但对本题 n ≤ 300 的限制来说两者都轻松 AC,优先使用版本一
### 执行过程演示(s = "leetcode")
```
wordDict = ["leet", "code"] → 哈希集合: {"leet":true, "code":true}
n = 8, dp 初始: [T, F, F, F, F, F, F, F, F]
i=1: s[0:1]="l"
j=0: dp[0]&&ws["l"] → T && F → 不更新
i=2: s[0:2]="le"
j=0: dp[0]&&ws["le"] → T && F
j=1: dp[1]&&ws["e"] → F && ... → 跳过
i=3: s[0:3]="lee"
j=0: dp[0]&&ws["lee"] → T && F
j=1: dp[1]&&ws["ee"] → F && ... → 跳过
j=2: dp[2]&&ws["e"] → F && ... → 跳过
i=4: s[0:4]="leet" ← 注意!
j=0: dp[0]&&ws["leet"] → T && T → dp[4]=true ✓! break
i=5: s[0:5]="leetc"
j=0: dp[0]&&ws["leetc"] → T && F
j=1: dp[1]&&... → F 跳过
j=2: dp[2]&&... → F 跳过
j=3: dp[3]&&... → F 跳过
j=4: dp[4]&&ws["c"] → T && F → 不更新
i=6: s[0:6]="leetcod"
j=4: dp[4]&&ws["cod"] → T && F → 不更新
(其余 j 对应 dp[j]=F,跳过)
i=7: s[0:7]="leetcode"
j=4: dp[4]&&ws["code"] → T && T → dp[7]=true ✓! break
等等——上面是 i=7 子串只有6个字母,修正:
i=7: s[0:7]="leetcode" ← 错了,是7个字符 "leetco"
... 都不匹配 → dp[7]=false
i=8: s[0:8]="leetcode"
j=0: dp[0]&&ws["leetcode"] → T && F
j=1: dp[1]&&... → F 跳过
...
j=4: dp[4]&&ws["code"] → T && T → dp[8]=true ✓! break
最终: dp[8] = true ✅
拆分方式: "leet" + "code"
```
### 复杂度分析
- **时间复杂度:O(n² · L)** — 两层循环共 n(n+1)/2 次迭代,每次 substring 切割和哈希查找耗时 O(L)(L 为子串长度)。代入题目约束 n ≤ 300,最坏约 4.5 × 10⁴ 次操作
- **空间复杂度:O(n + Σ|w|)** — dp 数组占用 O(n),哈希集合存储字典所有单词占用 O(Σ|w|)
> 使用版本二(maxLen 剪枝)后时间复杂度降为 **O(n · maxLen · L)**,即 O(n · maxLen²),上限约 300 × 20² = 1.2 × 10⁵ 次操作。
## 举一反三
这道题是 **"线性 DP + 枚举分割点"** 的经典代表,也是理解字符串 DP 的入口题:
- [[84-完全平方数]] — 同样是"枚举最后一项做决策"的 DP 模式,只是这里选的是"词"而非"数"
- [[85-零钱兑换]] — 本质相通:都是给定一组可选元素,判断/求是否能拼出目标。零钱兑换求最小数量,本题只求可行性(布尔版)
- LeetCode 140. 单词拆分 II —— **变种**:不只返回 true/false,而是返回所有可能的拆分结果,需要回溯枚举
- LeetCode 464. 我能赢吗 —— 博弈论 + 状态压缩 DP,思想有相似之处
> [!summary] 📌 本节要点
> 1. 定义 dp[i] = s[:i] 能否用字典词拼接
> 2. 状态转移:dp[i] = OR(dp[j] && s[j:i] ∈ wordDict),枚举分割点 j
> 3. 边界 dp[0] = true,初始化其余为 false
> 4. 用哈希集合将字典查询降到 O(1),否则退化为 O(n² · m · L)
> 5. 可优化:maxLen 剪枝让内层循环最多跑 20 次;一旦 dp[n]=true 可提前终止
> 6. 复杂度 O(n² · L),n ≤ 300 时非常高效