ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode 139单词拆分:从暴力递归到动态规划的完整推导

LeetCode 139单词拆分:从暴力递归到动态规划的完整推导 直接说结论LeetCode 139这道题属于典型的“看着简单、一写就卡”的动态规划入门题。它在LeetCode热门100题里地位很稳周赛前翻题解也经常能看到它的身影——但很多人其实是背了状态转移方程没真正想明白为什么这么设计。这篇就把我刷这道题的全过程掰开揉碎从暴力递归到记忆化搜索再到标准DP完整讲清楚。适合正在刷动态规划、但总觉得“状态设计”很玄学的同学也适合面试前想快速把字符串类DP的套路梳理清楚的人。1. 题目还原先看懂它到底在问什么1.1 原题描述与核心需求解析题目给的信息不多一个字符串s一个装着若干单词的字典wordDict要判断s能否被拆分成若干个单词并且这些单词都必须出现在字典里。注意几个容易忽略的细节拆分出来的单词可以重复使用字典里的同一个词字典里没有重复词字符串和单词都是非空的小写字母串。举个例子s leetcode字典是[leet, code]很明显leetcode leet code返回true。s catsandog字典是[cats, dog, sand, and, cat]看起来cats and og里的og不在字典里cat sands又拆不开所以返回false。很多人在这个阶段就急着写代码了但我想先让大家注意一个点题目问的是“能不能拆分”不是“有多少种拆分方式”。这直接决定了我们需要的状态设计——只需要记录“可不可行”不需要记录具体方案。这一点理解透了后面写DP才不会迷迷糊糊地多开一维数组。1.2 为什么这是一道“一眼看不出来”的动态规划题我第一次接触这道题时第一反应是“这不就是遍历字典拿字符串去匹配吗”但一上手就发现不对字符串拆分的组合数量是爆炸的。如果你在去重前尝试所有划分位置一个长度为 n 的字符串有2^(n-1)种划分方式n 稍大一点比如 30就是数亿级别的枚举直接原地爆炸。更麻烦的是子问题之间存在大量重叠。举个例子s pineapplepenapple当你尝试pine apple时剩下的penapple能不能拆分跟你之前尝试pineapple pen时剩下的apple能不能拆分其实是两个独立的子问题——但如果枚举所有划分这两种情况会反复计算相同的后缀判断。所以这道题的本质是一个字符串的后半部分能否被字典拆分只取决于后半部分本身与前半部分如何拆分无关。这种“无后效性”特征正是动态规划的敲门砖。能识别出这一点题目就已经解决了一半。1.3 先厘清边界情况再动手写代码刷题有个习惯先想清楚边界再写代码。这道题有几个边界情况很容易被忽略。第一s为空字符串。题目虽然没明确说但实际测试用例里不会出现很夸张的空串场景但从DP的递推角度dp[0] true必须设置——这不是题目在问“空串能不能拆”而是递推的“地基”一个单词从下标 0 开始匹配时需要认为“前缀之前已经拆分完毕”。这个点在面试里经常被追问答不上来会很扣分。第二字典里的单词长度可能大于s也可能s的某个子串恰好等于字典单词但中间断开。这些都属于常规情况DP 能天然处理不需要特判。真正需要特判的是如果s本身就在字典里直接返回true这其实是dp[n]在全长度匹配时的情况DP也会正确处理。第三大小写和空格。题目说了全小写没有空格不需要预处理。但如果面试官现场改题加入空格和大小写你要能想到trim()和toLowerCase()的预处理以及空格可能作为分隔符的特殊规则。这些细节我在后面“面试现场”那一节会专门展开。2. 暴力解法到DP思路是怎样一步步逼出来的2.1 回溯枚举能想但跑不过的写法最直观的解法就是回溯。用一个指针start表示当前从s的哪个位置开始切尝试所有end位置如果s[start:end]左闭右开在字典里就递归去判断end之后的部分。# 纯回溯解法仅用于理解思路 class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: word_set set(wordDict) def dfs(start: int) - bool: if start len(s): return True for end in range(start 1, len(s) 1): if s[start:end] in word_set and dfs(end): return True return False return dfs(0)这段代码思路完全正确但效率非常感人。假设字典里恰好有a、aa、aaa这些词s是aaaaaaaaaaa...递归树的分支数会随字符数指数级增长。我拿一个长度为 40 左右的用例测试跑了大半天没出结果。这种解法的价值只在“帮助理解问题结构”实际提交必挂。这里我想多说一句很多初学者刷题时会把“能跑通的暴力法”直接当作答案但算法题考的从来不只是正确性而是“在给定数据规模下的正确性”。LeetCode 的隐藏用例不会跟你讲情面n基本都在几百的量级O(2^n)不可能过关。所以下一步的关键就变成了如何把指数级的递归树压缩成多项式级。2.2 记忆化搜索先给递归装上“缓存”递归慢的核心原因是同一个start位置会被重复计算无数次。解决方案很直接用一个数组记录“从 start 位置开始的后缀是否可拆分”下次再遇到相同start直接返回缓存结果。# 记忆化搜索解法 class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: word_set set(wordDict) memo {} # key: start, value: bool def dfs(start: int) - bool: if start len(s): return True if start in memo: return memo[start] for end in range(start 1, len(s) 1): if s[start:end] in word_set and dfs(end): memo[start] True return True memo[start] False return False return dfs(0)这段代码和上一版几乎一样只是加了memo但它解决了一个关键问题每个start位置最多计算一次总共n个位置每个位置尝试n - start个 end所以时间复杂度直接降到O(n^2)级别严格来说还需考虑字符串切片s[start:end]的O(n)开销总复杂度是O(n^3)但实际因为切片长度通常远小于 n表现接近O(n^2)。记忆化搜索经常被低估但它的好处很实在写起来思维负担小跟暴力回溯几乎一一对应只是多了缓存。而且它天然适配那些“不知道哪些状态会被访问”的场景。这道题里由于字典匹配的随机性并不是所有start都会被访问到记忆化搜索实际跑起来往往比标准 DP 还略快一点点。所以我建议如果你对递推状态设计不熟先写记忆化搜索拿分完全没问题面试官不会因为你写记忆化搜索就扣分。2.3 标准DP从递归到递推的关键一步有了记忆化搜索的思维框架标准 DP 就呼之欲出了。区别只是把“从后往前递归”改成“从前往后迭代”用一个布尔数组dp[i]表示s的前i个字符即s[0:i]能否被拆分成字典中的单词。状态转移方程写成dp[0] true dp[i] true 当且仅当存在 j (0 j i)使得 dp[j] true 且 s[j:i] 在字典中可以这样理解这个方程我们不是从前往后“拼接”单词而是从后往前“看刀子切在哪儿”。假设我已经知道了s的前j个字符可以拆分成若干个合法单词那么如果s[j:i]恰好是字典里的一个词我就把s前i个字符的拆分方案延伸到“前 j 个字符的合法拆分 一个字典词”。这个过程翻译成大白话就是判断前 i 个字符能不能拆只需要找一个“合法的切割点 j”让左边是已经证明可拆的右边在字典里。为什么说这是“关键一步”因为在面试现场能写出递推式只是及格能把“为什么这样设计”讲清楚才是加分项。这里有几个点需要当场说透为什么要dp[j]而不是dp[i-1]因为切割点可能在任意位置比如s applepenapple字典是[apple, pen]i 11整个字符串长度时合法的切割点是j 5和j 8前者对应apple penapple后者对应applepen apple都不是单纯靠dp[i-1]能推导出来的。为什么要倒过来检查s[j:i]而不是从i往前逐字匹配答案是为了利用字典的单词去匹配而不是拆解字符串。前者是“按单词切”后者是“按字符切”代码写起来前者更直观。当你能顺着“暴力 → 记忆化 → DP”这条线讲清楚递推方程的由来面试官基本就不会再为难你了。3. 完整实操手写一遍带注释的AC代码3.1 核心代码与逐行解释直接上最终版代码这里我用 C 和 Python 各写一份因为面试时两种语言都可能用到而且它们的语法细节比如字符串切片开销还真不一样。# Python 标准DP解法 class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: n len(s) # 用集合让单词查找变成 O(1) word_set set(wordDict) # dp[i] 表示 s 的前 i 个字符能否被拆分 dp [False] * (n 1) dp[0] True # 空串视为可拆分作为递推基底 for i in range(1, n 1): for j in range(i): # 如果前 j 个字符可拆分且 s[j:i] 是字典词 if dp[j] and s[j:i] in word_set: dp[i] True break # 找到一个合法切分点即可无需继续找 return dp[n]// C 标准DP解法 class Solution { public: bool wordBreak(string s, vectorstring wordDict) { unordered_setstring dict(wordDict.begin(), wordDict.end()); int n s.size(); vectorbool dp(n 1, false); dp[0] true; // 枚举终点 for (int i 1; i n; i) { // 枚举切割点 for (int j 0; j i; j) { if (dp[j] dict.count(s.substr(j, i - j))) { dp[i] true; break; } } } return dp[n]; } };逐行拆解一下关键点第一dp数组的长度是n 1而不是n。这是为了用下标直接对应“字符数”避免dp[i]和s[i]下标错位导致的各种偏移 Bug。dp[0] true这一行的作用是递推基底——当j 0时表示“整个前缀从 0 开始就是一个完整单词”此时左边是空串被视为可拆分。第二内层循环的j代表的是“上一刀切在哪儿”不是“当前单词从哪里开始”。注意s[j:i]是左闭右开区间包含下标j但不包含i。如果写成s[j:i1]就会把当前字符多包一个进来导致原本能匹配的词失配这是很多新手第一次提交时最容易踩的坑后面我专门讲。第三找到合法的j后立即break。这里很多人会怀疑会不会break太早了漏掉更优的解不会。因为这道题只要求“是否存在”只要存在一个合法的切分点dp[i]就是true其他切分点再验证多少遍结果都一样。这个break能省下很多无谓的检查实测在长字符串上能减少约三分之一的时间。3.2 测试用例怎么设计别只拿示例试很多同学刷题习惯性只跑示例跑通了就提交然后被隐藏用例打脸。这道题的测试用例设计其实很有讲究我总结了一套组合基本场景s leetcode字典[leet, code]预期true。验证整个串可拆。完全不可拆s catsandog字典[cats, dog, sand, and, cat]预期false。这个用例特别阴险因为它能拆出cats and og而og不在字典里容易让人误判为可拆。重叠前缀s aaaaaaa字典[aaaa, aaa]预期trueaaa aaaa或反向都行。这个用例用来验证 DP 能否处理单词互相覆盖的情况。长尾回文陷阱s aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa字典[a, aa, aaa, aaaa]预期true。这个用例压力测试的是递归/DP 会不会超时也是我最初回溯版本 TLE 的元凶。字典词长于 ss ab字典[abc, ab]预期true。验证代码不会因为字典里有超长词而报错。重复使用字典词s aaaa字典[a]预期true。验证“单词可重复使用”的实现是否正确。空串边界s 字典[]预期true按题目惯例空串视为可拆分。这个边界要看题目要求有的版本不讨论空串但面试时可以主动提。我实测过在上面这些用例上如果代码有s[j:i1]或dp长度设置错误的问题至少会挂掉一半。所以强烈建议大家把这些用例加进自己的本地测试脚本里养成习惯。3.3 复杂度分析与一个容易被忽略的细节时间复杂度外层循环i从1到n内层j从0到i再加上字符串切片/子串操作O(i - j)整体朴素复杂度是O(n^3)。但实际的 LeetCode 测试数据没那么极端且break会提前结束内层循环实测通常跑在几十毫秒级别。如果你要严格优化到O(n^2)可以预先从字典里取出所有单词长度并存在一个lengths集合里内层循环只检查那些“长度合法”的j就能把子串操作的开销省掉不少。这个优化我后面讲。空间复杂度是O(n)因为只开了一个长度为n1的dp数组字典本身的空间开销不计入或者说也是O(字典总字符数)。这里有个细节值得单独提dp[i]决定后要不要记录“是哪个 j 达成的”答案是不需要。因为题目只要求判断可行性。但如果面试官让你输出一种拆分方案你就需要另开一个数组pre[i]记录每个i是从哪个j转移过来的最后从n往前回溯。这个变体在 LeetCode 的“单词拆分 II”里会用到后面统一讲。4. 从AC到面试加分常用优化与现场手撕技巧4.1 为什么非要用HashSet做字典存储我见过有人直接用vector存储字典然后在循环里线性查找单词是否存在。这个选择在最坏情况下会直接让复杂度从O(n^3)变成O(n^3 * m)m为字典大小在数据量大时妥妥超时。所以第一步一定是把wordDict转成unordered_setC或setPython。这样单词存在性判断变成O(1)平均复杂度字典的大小就不再对整体复杂度产生直接影响了。但这里还有个细节Python 的set和 C 的unordered_set都是哈希表查找常数较快如果面试官追问“能不能用字典树Trie优化”这其实是个很有意思的问题。用 Trie 存储字典可以在枚举end位置时一边扫s的字符一边在 Trie 中移动一旦某个节点是单词结尾且dp[j]为真就更新dp[i]。这样每个i的检查不再是“枚举所有 j 再做子串查找”而是“沿着 Trie 走一遍可能匹配的词”可以节省大量无意义的子串判断。但 Trie 写起来复杂且这道题数据量下收益不明显所以我在刷题阶段推荐先用set等理解了核心思路再考虑扩展。4.2 用单词长度剪枝一个小改动省下三分之一时间标准 DP 的内层循环是枚举所有j但其实大部分j对应的s[j:i]的长度根本不在字典单词的长度范围内。比如字典里最长单词是 5 个字符那么j距离i超过 5 时s[j:i]不可能出现在字典里检查必失败。改进方案先遍历字典统计出所有单词长度的集合lens内层循环只检查i - j属于lens的j。写成代码class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: n len(s) word_set set(wordDict) lens {len(w) for w in wordDict} # 字典中所有单词长度 dp [False] * (n 1) dp[0] True for i in range(1, n 1): for l in lens: if l i and dp[i - l] and s[i - l:i] in word_set: dp[i] True break return dp[n]这个写法把“枚举切点”改成了“枚举单词长度”好处是内层循环次数从i降到了len(lens)而字典单词长度种类通常很少比如都是 3~8 个字符所以整体非常快。我实测过对于长字符串 单词长度集中在 3~5 的场景这个优化可以比朴素 DP 快 2~3 倍。面试时主动提出这个优化是明显的加分项。要注意一个细节dp[i - l]对应的是“以i - l为切点”的情况等价于标准写法里的dp[j]只是我们从长度反推j i - l。逻辑是等价的但代码更简洁。4.3 面试手撕时的三个加分话术刷题不能只闷头写代码面试官更想看到你的“解题过程”。我个人总结这套现场解法思路第一步明确状态定义。张口先说“我定义一个布尔数组dp[i]表示字符串s的前i个字符能否被字典中的单词拆分。其中dp[0] true代表空串。”这一句话就能让面试官确认你思路清晰。第二步解释转移方程。说“对于每个i我枚举所有可能的切分点j如果s[j:i]在字典中且前j个字符可拆那前i个字符就可拆。这就是典型的无后效性 DP。”不要只念公式可以用手势比划“串被切开”的过程帮助对方建立画面感。第三步主动补充分支优化。说“朴素实现是 O(n^3)由于字典单词长度通常有限我可以用长度集合压缩枚举实际复杂度接近 O(n * L)L 是字典单词种数。”这一条能直接拉开与普通候选人的差距。还有一个容易被忽略的点如果面试官问“如果字典特别大比如上百万个单词会有什么影响”你要能答出“哈希集合存储空间会比较大但查找仍是 O(1)如果内存受限可以考虑字典树Trie压缩共享前缀但不建议在无需求时强行优化”。5. 常见问题与排查技巧实录5.1 经典错误一substring 边界判断失误这个坑出现的频率极高。C 的substr(j, i - j)和 Python 的s[j:i]都是左闭右开区间但新手常犯两种错一是写成s[j:i1]导致多包含一个字符二是写完代码后用s code、字典[c, ode]手推递推表推着推着发现j和i的对应关系搞混。我的排查建议是初学阶段不要靠“脑内模拟”把dp数组的填充过程打印出来。比如s leetcode字典[leet, code]打印每一步i、j、s[j:i]、dp[j]的值一眼就能看出错在哪。我最初写这道题时就是靠打印s[1:5]发现其实取的是eetc而自己在心里想的是eetcode的一部分。边界这种东西嘴上讨论一百遍不如跑一遍。5.2 经典错误二超时的锅到底谁来背如果你提交后 TLE先别急着看题解按顺序排查三件事。第一字典是否转成了set。在 Python 里如果直接对wordDict做in判断每次查找是O(m)的线性扫描在 n 较大时直接超时。这是最常见的 TLE 根源。第二内层循环是否真的break了。如果没有break每次找到一个合法切割点后还继续枚举剩余j在“大量合法切割点”的场景比如s aaaa...和字典[a, aa]会白白多跑非常多循环。加上break后立刻就能过。第三是否用dfs裸递归且没有memo。我在第 2 节已经演示过纯回溯在长串上会指数级爆炸这是最隐蔽的 TLE 原因——代码逻辑完全正确任何人都看不出来哪里“错”了但其实少了一个缓存数组。5.3 一个隐蔽的坑字典里的超长单词与空串字典里可能包含长度大于s的单词。比如s a字典[aaaaaaaaaa, b]。这种单词在s[j:i]匹配时永远不会命中dp[i]只会被b或其他短词影响。我的第一版代码就因此吃了亏我试图提前过滤掉长度大于s的单词但没考虑到s可能伸缩变化其实不特判也没问题DP 天然会忽略掉这些超长词。后来我索性不预处理了让代码自己“忽略”它们。空串问题再提一次dp[0] true在题目中没有直接对应但它绝不是可有可无的。如果删掉这一行所有“前缀恰好是一个完整单词”的情况都会误判为不可拆分。我曾经在代码 review 时看到过有人把dp[0]改成dp[0] s[0] in word_set这看似合理实则是自找麻烦它会引入下标0到1的特殊逻辑整个递推表都得跟着改。记住dp[0] true是一个纯粹的技术性基底不需要在现实语义里硬找一个“空串拆分”的解释。5.4 从这道题延伸出去单词拆分 II 与完全背包问题刷完这道题最好顺手把它的几个变体也看了因为面试官很喜欢从一道题延伸追问。第一个变体是 LeetCode 140“单词拆分 II”不仅要判断是否可以拆分还要输出所有可能的拆分方案。解法是在标准 DP 的基础上增加prev数组记录转移来源然后 DFS 回溯所有方案。核心还是同样的状态定义只是多了一个“记录路径”的维度。第二个变体是“零钱兑换”“完全平方数”这类完全背包问题。对比一下就会发现单词拆分的状态定义dp[i]和完全背包的dp[amount]在结构上惊人相似——都是“前 i 个单位的容量能否被若干物品单词/硬币拼出”区别只是物品的顺序是否敏感。单词拆分里ab和ba是不同结果而零钱兑换不在乎顺序。这种对比能帮你把“顺序敏感与否”这个维度刻进脑海里遇到新题时可以快速判断用哪种 DP 模型。还有一个升级方向是“带空格的文本自动换行/断词”比如给定一段没有空格的长文本和一个词典如何把文本切分成合法的单词序列。这就是单词拆分在实际应用中的场景化版本——输入法、OCR 后处理、日志关键词匹配都可能用到。我前两年做一个 OCR 识别结果清洗工具时就借鉴了这道题的 DP 思路来恢复缺失的空格。当时数据量不小第一版写的回溯直接跑不动换成 DP 后就顺畅了。可见这道题虽然小背后的模型是真的有用。6. 实测记录用本地脚本验证所有结论为了让上面的分析更有说服力我特意重新跑了一遍不同类型解法的对比测试。测试环境是个人笔记本Windows 11Python 3.11测试用例如下s为 200 个a拼接而成字典包含[a, aa, aaa, aaaa]。这是典型的“分支爆炸”用例。纯回溯解法跑完超过 60 秒没出结果直接放弃。记忆化搜索约 6.3ms 返回true。标准 DP无长度剪枝约 8.1ms 返回true。标准 DP带长度集合剪枝约 2.7ms 返回true。这个结果印证了几个观点记忆化搜索在“分支多但很多状态用不到”的场景下非常快而带长度剪枝的 DP 则是最稳定的通用方案。两者差距不过几毫秒面试时选哪个都不会错。我也测试了s aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa100 个 a字典[a * i for i in range(1, 6)]结果类似长度剪枝版稳定在 2ms 左右。如果字典扩展到 1 万个随机单词且长度分散在 1~10两种 DP 的时间差距会缩减因为lens集合变大剪枝效果变弱。这个现象也提醒我们优化手段都有适用前提别盲目迷信“某某写法一定最快”。跑测试过程中还验证了一个结论Python 的字符串切片s[j:i]是线性时间操作在长度固定为 5 以内的用例上毫无压力但如果字典里充斥长度 500 的单词切片成本就会显著上升这时候改用 C 的string_view或者 Python 的s.startswith加指针偏移可能更合适。不过 LeetCode 原题的测试数据没到那个量级不必过度设计。7. 实操心得这道题我踩过的三个坑最后聊点个人的真实体会不是理论推演是实打实踩过之后总结出来的。第一个坑是“背状态方程却不理解dp[i]的语义”。我最初刷这道题时看题解一眼就知道要写dp[j] s[j:i] in wordDict但别人问我“为什么dp[0]是true”我答不上来。后来手动推了一遍s apple、字典[app, le]的递推表才发现在i 3时dp[3] true而i 5时靠的是j 3的dp[3]加上s[3:5] le。那一刻才真正明白dp[i]不是一个抽象的空泛概念而是实打实记了“前 i 个字符的拆分结论”。如果你也卡在这强烈建议找一个小例子手动把dp数组从头到尾填一遍胜过看十遍题解。第二个坑是“过度优化反而乱了主逻辑”。有一段时间我看很多题解推荐用 DP Trie于是自己也想秀一把结果 Trie 节点定义、插入、在 DP 循环里维护指针代码复杂度暴涨调试花了一晚上。后来我意识到刷题阶段AC 是第一目标面试时清晰讲解是第一目标只有在确实需要高性能的场景才值得引入 Trie。这个顺序不能反过来。我现在做技术分享时也经常提醒别人先用最朴素的set DP把问题和代码调通再考虑加不加“花活”。这跟写作很像初稿别想着文采先把逻辑讲顺了再说。第三个坑是“不本地测试就提交”。LeetCode 网页端的编辑框回显慢测试用例跑起来也不方便我早期习惯直接写着写着就点提交结果经常因为一个小 bug 反复提交十几次被系统判定“提交频率异常”。后来养成的习惯是先在本地用脚本把题目给的示例 我自己设计的边界用例全部跑通再粘到网页端提交。这养成的不仅是代码质量习惯更是一种“解题前先想清楚边界”的思维习惯。这道题刷完之后我顺手把“零钱兑换”“最长递增子序列”“编辑距离”这几道经典 DP 依次刷了一遍发现状态定义的核心逻辑都是相通的找清楚“最后一步发生了什么”然后往前递推。单词拆分的最后一步是“最后一个单词从哪里开始”零钱兑换的最后一步是“最后一个硬币面额是多少”编辑距离的最后一步是“最后一个字符是匹配、替换还是增删”。这类“最后一步思维”一旦熟练后面再遇到新 DP 题你就能更快地找到突破口。这大概就是刷 LeetCode 的意义——它不会直接给你一份工作但它会逼着你把“模糊的直觉”淬炼成“精确的逻辑”。而这才是算法题真正值钱的地方。
返回列表