单词拆分 II 图解题解
这道题到底在问什么
- 输入
- s="catsanddog", dict=[cat,cats,and,sand,dog]
- 输出
- ["cats and dog","cat sand dog"]
- 输入
- s="catsandog", dict=[cats,dog,sand,and,cat]
- 输出
- [](尾部 "og" 拼不出)
先想最直接的笨办法
记住「枚举词典词切首段、递归拼后缀、memo 缓存起点结果」,下面看调用栈逐层展开。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 140 单词拆分 II 返回把 s 拆成词典单词的所有句子:记忆化搜索,memo[i] 存从下标 i 起能拼出的全部句子,枚举前缀词在词典就递归后缀拼接,起点只算一次避免指数重算,O(n³+输出量)。
这道题到底要把 s 拆出什么
给字符串 s 和词典 wordDict,把 s 切成若干段、每段是词典单词、拼回 s,列出所有句子。s="catsanddog"、词典 [cat,cats,and,sand,dog] 拼成 "cats and dog" 和 "cat sand dog";拼不出(如 "catsandog")返回 []。不是判定题。
把所有切法枚举一遍为什么会炸
每个位置都能切一刀或往下走,二选一分叉,切法随串长指数膨胀,数不完。更亏的是同一截后缀被反复重算:不同前缀走到同一位置,剩下那段被从头重拆。把「某位置起能拼哪些句子」算一次存下复用,重复展开就压下去。
状态定成「从下标 i 起能拼出的所有句子」
递归函数 dfs(i)(递归即函数调用自己、拆成同类小问题):返回后缀 s[i..] 能拼出的所有句子,答案是 dfs(0)。
为什么按起点 i 记账?后缀能拼哪些句子只跟起点下标有关,跟前缀怎么走到无关:"cat" 或 "cats" 走到下标 7,剩下的 "dog" 拼法都一样。给每个起点配 memo[i](记忆化:算过的句子列表存下、下次直接取),起点只 n+1 个、各算一次。
枚举前缀词递归后缀,空串为什么是成功标记
dfs(i) 从 i 往右枚举右端 j 取前缀词 s[i..j);词典先塞进哈希集合(O(1) 判断词在不在),词在里面就递归 dfs(j) 求后缀、把每句拼到词后。后缀非空拼「词 + 空格 + 后缀」,空串就只留词。
递归出口在 i 等于串长 n:整串拼满,返回只含空串的列表 [''],这是「拼完了」的成功标记,让上层的词有东西可接。若某起点一个词都接不出返回 [],才是此路不通。[''] 有一个空句子、[] 一个都没有。
拿 catsanddog 亲手走一遍调用栈
顺着调用栈(函数一层层调自己形成的那摞调用)走:s="catsanddog"。dfs(0) 试 "cat" 递归 dfs(3),dfs(3) 试 "sand"(下标 3 到 7)递归 dfs(7),dfs(7) 试 "dog" 递归 dfs(10),dfs(10) 到串尾返回 ['']。
往回拼:dfs(7) 得 ["dog"] 存 memo[7],dfs(3) 拼 "sand",dfs(0) 收到 "cat sand dog"。dfs(0) 再试 "cats" 递归 dfs(4),dfs(4) 试 "and" 又要 dfs(7),这次 memo[7] 已缓存直接返回,拼成 "and dog",dfs(0) 收到 "cats and dog"。最终返回这两句。
复杂度为什么甩不掉「输出量」,['']和[]差一个空串却天差地别
每个起点算一次,但内层枚举右端 j、每次要构造并哈希子串 s[i..j),构造就耗 O(j−i),枚举加构造升到 O(n³);句子可能指数级多,整体 O(n³ + 输出量)。空间是递归深度 O(n) 加 memo。
整串本身是词时 dfs 返回它这一句;拼不通那层返回 [],dfs(0) 得空列表;单字符同理。最阴的是拿 [''] 当失败:它是拼满整串的成功信号,误当空列表,句子会在最后一层被丢光。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记住「枚举词典词切首段、递归拼后缀、memo 缓存起点结果」,下面看调用栈逐层展开。
- 4调用 dfs(0):求从下标 0(灰色之后)开始能拼出的所有句子,压入调用栈。
- 5在 dfs(0) 里试切词 "cat"(下标 0 到 2):它在词典里!接着递归 dfs(3) 求后缀 "sanddog" 的所有句子。
- 6调用 dfs(3):求从下标 3(灰色之后)开始能拼出的所有句子,压入调用栈。
- 7在 dfs(3) 里试切词 "sand"(下标 3 到 6):它在词典里!接着递归 dfs(7) 求后缀 "dog" 的所有句子。
- 8调用 dfs(7):求从下标 7(灰色之后)开始能拼出的所有句子,压入调用栈。
- 9在 dfs(7) 里试切词 "dog"(下标 7 到 9):它在词典里!接着递归 dfs(10) 求后缀 "" 的所有句子。
- 10调用 dfs(10):求从下标 10(灰色之后)开始能拼出的所有句子,压入调用栈。
- 11i=10 已到串尾,说明前面的词正好把整串拼完。直接返回 [""](一个空句子,成功标记),这是递归出口,不写入 memo。
- 12dfs(10) 返回 1 个后缀句子,把 "dog" 拼到每个前面,dfs(7) 目前收集到 1 个句子。
- 13dfs(7) 的所有切法试完,得到 1 个句子,写入 memo[7] 并返回,弹出调用栈。
- 14dfs(7) 返回 1 个后缀句子,把 "sand" 拼到每个前面,dfs(3) 目前收集到 1 个句子。
- 15dfs(3) 的所有切法试完,得到 1 个句子,写入 memo[3] 并返回,弹出调用栈。
- 16dfs(3) 返回 1 个后缀句子,把 "cat" 拼到每个前面,dfs(0) 目前收集到 1 个句子。
- 17在 dfs(0) 里试切词 "cats"(下标 0 到 3):它在词典里!接着递归 dfs(4) 求后缀 "anddog" 的所有句子。
- 18调用 dfs(4):求从下标 4(灰色之后)开始能拼出的所有句子,压入调用栈。
- 19在 dfs(4) 里试切词 "and"(下标 4 到 6):它在词典里!接着递归 dfs(7) 求后缀 "dog" 的所有句子。
- 20到达 dfs(7),memo[7] 已有缓存,直接返回,不再展开(这正是记忆化省时的关键)。
- 21dfs(7) 返回 1 个后缀句子,把 "and" 拼到每个前面,dfs(4) 目前收集到 1 个句子。
- 22dfs(4) 的所有切法试完,得到 1 个句子,写入 memo[4] 并返回,弹出调用栈。
- 23dfs(4) 返回 1 个后缀句子,把 "cats" 拼到每个前面,dfs(0) 目前收集到 2 个句子。
- 24dfs(0) 的所有切法试完,得到 2 个句子,写入 memo[0] 并返回,弹出调用栈。
- 25调用栈全部弹空,dfs(0) 返回最终答案:共 2 个句子:"cat sand dog"、"cats and dog"。记忆化让每个起点只算一次,把可能指数级的重复展开压了下来。
⚠️ 容易写错的地方
✗ 错:不加 memo,纯回溯重复展开
✓ 对:用 memo[i] 缓存每个起点的句子列表
同一个后缀起点会被多条前缀反复递归到;不缓存会退化成指数级重复计算,加 memo 后每个起点只算一次
✗ 错:拼接时无脑加空格
✓ 对:tail 为空(到结尾)时不加空格
dfs(n) 返回的空串代表「正好拼完」,若拼成 "词 + 空格 + 空串" 会多出尾部空格;判 tail 是否为空决定加不加
✗ 错:只判断能否拆分(返回布尔)
✓ 对:本题要返回所有具体句子
这是「单词拆分 II」,要列出全部拼法;若只需判断可行性(I)用布尔 DP 即可,但这里得收集句子列表
完整代码(Python / C++ / Java)
Python
from typing import List
from functools import lru_cache
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:
words = set(wordDict)
@lru_cache(None)
def dfs(i: int):
if i == len(s):
return ['']
ans = []
for j in range(i + 1, len(s) + 1):
word = s[i:j]
if word in words:
for tail in dfs(j):
ans.append(word if not tail else word + ' ' + tail)
return ans
return dfs(0)C++
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace std;
class Solution {
public:
vector<string> wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> words(wordDict.begin(), wordDict.end());
unordered_map<int, vector<string>> memo;
function<vector<string>(int)> dfs = [&](int i) -> vector<string> {
if (memo.count(i)) return memo[i];
if (i == (int)s.size()) return vector<string>{""};
vector<string> ans;
for (int j = i + 1; j <= (int)s.size(); ++j) {
string word = s.substr(i, j - i);
if (words.count(word)) for (auto tail : dfs(j)) ans.push_back(tail.empty() ? word : word + " " + tail);
}
return memo[i] = ans;
};
return dfs(0);
}
};Java
import java.util.*;
class Solution {
Set<String> words;
Map<Integer, List<String>> memo;
String s;
public List<String> wordBreak(String s, List<String> wordDict) {
this.s = s; words = new HashSet<>(wordDict); memo = new HashMap<>();
return dfs(0);
}
private List<String> dfs(int i) {
if (memo.containsKey(i)) return memo.get(i);
if (i == s.length()) return new ArrayList<>(Arrays.asList(""));
List<String> ans = new ArrayList<>();
for (int j = i + 1; j <= s.length(); j++) {
String word = s.substring(i, j);
if (words.contains(word)) for (String tail : dfs(j)) ans.add(tail.isEmpty() ? word : word + " " + tail);
}
memo.put(i, ans);
return ans;
}
}复杂度
时间
O(n³ + 输出量)
n 是串长。每个起点 i 只算一次(记忆化),但内层枚举右端 j、且每次都要构造并哈希子串 s[i..j)(本身就要 O(j−i) 的时间),所以光「枚举 + 查词」就是 O(n³)(也可记最大词长 L、写成 O(n²·L));若不计子串构造、只看集合查询才是 O(1)。再加上合法句子可能指数级,拼接与收集的代价取决于输出量,故整体 O(n³ + 输出量)
空间
O(n + 输出量)
递归深度 O(n),memo 与最终句子列表占「输出量」级空间
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 单词拆分 II 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
和单词拆分 I(LeetCode 139)是什么关系?+
139 只问 s 能不能被拆成词典单词,是道是非题,dp[i] 存布尔值、发现一种拆法就够,可以只判可行性、大量剪枝。本题 II 要的是所有句子,不能一见可行就停,必须把每条拼法都收集回来,于是 dfs(i) 返回的是句子列表而非布尔。会了 139 的判定,II 的难点在「返回并拼接所有方案」和「按起点记忆化避免重复展开」。
为什么按起点 i 记忆化就对,不会因为走的前缀不同而拼错?+
因为 dfs(i) 只回答「后缀 s[i..] 能拼出哪些句子」,这个答案只由起点 i 决定,跟你是从 "cat" 还是 "cats" 走到 i 完全无关。两条不同前缀走到下标 7,面对的都是同一截 "dog",拼法自然一样。所以 memo[7] 存一次、两条路共用,既不会算错也省掉重复展开。
为什么复杂度里甩不掉「输出量」,不能做到更快吗?+
因为答案本身可能有指数级那么多条句子,比如 s 是一长串 "aaaa..."、词典有 "a" 和 "aa" 时,合法拆法会随长度爆炸。无论算法多聪明,把这些句子逐条构造并输出就得花与条数成正比的时间,这部分是问题规模决定的下限,省不掉。记忆化能压掉的是「重复的后缀展开」,压不掉「答案很多、必须逐条吐出来」这块。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 单词拆分 II 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。