单词拆分 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 步)
最优解:一步一步想明白
- 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」判断有没有解,再决定要不要枚举句子?+
可以,而且是常见优化。先用一遍 O(n²) 的布尔 DP 判断 s 整体能否被拆分;若不能,直接返回空列表,省去可能很深的无效递归(典型如 "aaaa...aab" 这种大量前缀可拼、但尾部死路的退化数据)。能拆分时再跑记忆化 DFS 收集所有句子。这个「先可行性剪枝、再枚举」的两段式能避免最坏情况的无谓展开。
为什么这道题不能像普通 DP 那样只用一维布尔数组解决?+
「单词拆分 I」只问能否拆分,答案是布尔,一维 DP 足够。但 II 要列出所有具体句子,句子数量可能指数级,无法压进布尔或计数的 DP 状态里,必须真正地把每个起点的句子列表构造出来,所以用「记忆化 DFS 返回列表」而不是「布尔 DP」。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 单词拆分 II 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。