拼图大师:从"暴力尝试"到"动态规划"的拆字艺术
LeetCode 139. 单词拆分 ------ 当你面对一串字符,怎么判断它是不是一堆单词的"拼图"?
一、故事开场:你是拼图大师
想象你面前有一盒拼图碎片,上面写着各种单词:"leet"、"code"、"apple"、"pen"...
现在有人给你一幅完整的画:"leetcode"。
你的任务是:**能不能用盒子里的碎片,把这幅画完整地拼出来?**碎片可以重复使用,但必须是连续的、不能重叠。
比如 "leetcode" 可以拼成 "leet" + "code",所以答案是 true。
但 "catsandog" 用 ["cats", "dog", "sand", "and", "cat"] 就拼不出来 ------ 无论你从哪开始拆,总会剩下几个孤零零的字母。
这就是 LeetCode 第 139 题 ------ 单词拆分。它看起来像个字符串匹配题,实际上藏着递归与动态规划的精髓。
二、暴力解法:为什么不直接枚举所有拼法?
最笨的方法:枚举字符串所有可能的切分方式,检查每一部分是不是都在字典里。
比如 "leetcode",切分方式有:
l | e | e | t | c | o | d | ele | etc | odeleet | code- ...
切分方式是指数级增长的。对于一个长度为 nnn 的字符串,切分点的组合数是 O(2n)O(2^n)O(2n)。
面试官听了会打哈欠。我们需要更聪明的方法。
三、解法一:记忆化搜索(拼图大师的直觉)
核心思路
作为一个拼图大师,你的直觉是什么?
从画的左边开始,拿一块碎片比划一下。对上了?好,把这块拿掉,看看剩下的部分能不能继续拼。
这就是深度优先搜索(DFS)。
"leetcode"
↑
尝试 "l" → 不在字典 ❌
尝试 "le" → 不在字典 ❌
尝试 "lee" → 不在字典 ❌
尝试 "leet" → 在字典 ✅
↓
递归检查 "code"
↑
尝试 "c" → 不在字典 ❌
尝试 "co" → 不在字典 ❌
尝试 "cod" → 不在字典 ❌
尝试 "code" → 在字典 ✅
↓
递归检查 ""
↑
空了,成功!✅
问题:重复劳动
但如果画很长,你可能会反复面对同一个"剩余部分"。比如:
"applepenapple"
↑
"apple" 后面剩 "penapple"
↑
"pen" 后面剩 "apple"
如果另一个分支也到了 "apple" 这个剩余部分,你难道要重新算一遍吗?
解决:记忆化(Memoization)
用一个小本本(memo 数组)记下:从位置 i 开始,能不能拼成功?
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet<>(wordDict);
Boolean[] memo = new Boolean[s.length()]; // null = 未计算
return dfs(s, wordSet, 0, memo);
}
private boolean dfs(String s, Set<String> wordSet, int index, Boolean[] memo) {
if (index == s.length()) return true; // 拼完了!
if (memo[index] != null) return memo[index]; // 查小本本
for (int i = index + 1; i <= s.length(); i++) {
String word = s.substring(index, i);
if (wordSet.contains(word) && dfs(s, wordSet, i, memo)) {
memo[index] = true;
return true;
}
}
memo[index] = false;
return false;
}
}
关键点: Boolean[] 而不是 boolean[],因为默认值 null 代表"还没算过"。
复杂度
- 时间 :O(n×m×l)O(n \times m \times l)O(n×m×l) ------ nnn 为字符串长度,mmm 为字典大小,lll 为单词平均长度
- 空间 :O(n)O(n)O(n) ------ 递归栈 + memo 数组
评价
就像一个老练的拼图大师,他一边试一边记:"从第 5 块开始我试过了,不行。"下次再遇到,直接翻小本本,不再浪费时间。
四、解法二:动态规划(拼图大师的"上帝视角")
核心思路
DFS 是"从左到右试",动态规划则是"把每个位置能不能拼出来,先算好存起来"。
定义 dp[i]:字符串的前 i 个字符 (也就是 s[0..i-1])能不能被拆分。
"leetcode"
0 1 2 3 4 5 6 7 8
l e e t c o d e
↑ ↑
dp[0] dp[4]
dp[0] = true------ 空字符串,默认可以dp[4] = true------"leet"在字典里dp[8] = true------"code"在字典里,且dp[4] = true
状态转移
对于每个位置 i,检查字典里的每个单词 word:
如果:dp[i - word.length()] == true (前面能拼出来)
且:s[i-word.length() .. i-1] == word (末尾正好是这个单词)
那么:dp[i] = true
代码实现
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true; // 空串可以被拆分
for (int i = 1; i <= n; i++) {
for (String word : wordDict) {
int len = word.length();
if (i >= len && dp[i - len]
&& s.substring(i - len, i).equals(word)) {
dp[i] = true;
break; // 已经找到一种拆分方式,够了
}
}
}
return dp[n];
}
}
填表演示(以 "leetcode" 为例)
| i | 子串 | 检查单词 | dpi-len | 匹配? | dpi |
|---|---|---|---|---|---|
| 0 | "" |
--- | --- | --- | true(初始化) |
| 1 | "l" |
"leet" |
dp-3 ❌ | --- | false |
| 2 | "le" |
"leet" |
dp-2 ❌ | --- | false |
| 3 | "lee" |
"leet" |
dp-1 ❌ | --- | false |
| 4 | "leet" |
"leet" |
dp0=true | ✅ | true |
| 5 | "leetc" |
"code" |
dp1=false | --- | false |
| 6 | "leetco" |
"code" |
dp2=false | --- | false |
| 7 | "leetcod" |
"code" |
dp3=false | --- | false |
| 8 | "leetcode" |
"code" |
dp4=true | ✅ | true ✅ |
复杂度
- 时间 :O(n×m×l)O(n \times m \times l)O(n×m×l) ------ 与 DFS 同级
- 空间 :O(n)O(n)O(n) ------ 一维 dp 数组
评价
就像一个拼图大师站在高处俯瞰整张桌子,他不急着动手,而是先在心里盘算:"拼到第 5 块时,桌上已有的碎片能不能支撑下去?"他把每个位置的可能性都预先算好,最后只扫一眼终点就知道答案。
五、两种境界对比
| 境界 | 算法 | 核心思想 | 代码难度 | 推荐指数 |
|---|---|---|---|---|
| 直觉派 | 记忆化搜索(DFS) | 从左到右试,记住失败的经验 | 低 | ⭐⭐⭐⭐ |
| 规划派 | 动态规划 | 预先算出每个位置的可能性 | 中 | ⭐⭐⭐⭐⭐ |
选哪个?
- 面试写代码 → 动态规划,代码短、无递归栈风险、最容易一次写对
- 理解问题本质 → 先学 DFS + Memo,直觉上最自然
- 实际工程 → 两者都行,DP 略快(无递归开销)
六、举一反三:这类题的识别特征
当你看到这类描述时,就要想到"单词拆分 DP":
- "给定一个字符串和一个字典"
- "能否用字典中的单词拼接出该字符串"
- "单词可以重复使用"
- 答案是一个 true / false
经典拓展题:
| 题目 | 变化点 | 难度 |
|---|---|---|
| 140. 单词拆分 II | 要求返回所有可能的拆分方案 | ⭐⭐⭐⭐ |
| 472. 连接词 | 字典中的单词也能被其他单词拼接 | ⭐⭐⭐⭐ |
七、写在最后
这道题最精华的部分,不是 DP 本身,而是那个从 DFS 到 DP 的转化过程:
DFS 是"遇到问题再想办法",DP 是"先把所有可能想好"。
人生也是如此。有人走一步看一步(DFS),遇到死胡同再回头;有人喜欢提前规划(DP),把每个节点的风险都算清楚。两种方法都能走到终点,但后者往往更从容。
预则立,不预则废。
欢迎在评论区分享你的理解,或者指出我表述不清的地方。一起进步!