Files

13 KiB
Raw Permalink Blame History

tags, create time
tags create time
算法/动态规划
字符串DP
哈希表优化
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 的子串。

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
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
Trie + DP O(n · maxLen) O(n + 字典总字符数) 适合大数据量场景

注:O(n² · L) 中的 L 来自 Go 中 s[j:i] 创建新字符串的成本,平均约等于子串长度的一半。实际评测中 n ≤ 300,完全轻松通过。

代码

Go 语言实现

版本一:哈希表 + DP(推荐 ✅)

// 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 并跳出内层循环——不需要枚举更多了

版本二:带最大词长剪枝

// 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 时非常高效