多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

单词拆分LeetCode 139:从动态规划到面试追问的完整拆解

单词拆分LeetCode 139:从动态规划到面试追问的完整拆解 LeetCode热题100刷到第82题单词拆分Word Break这道题我太有印象了——去年面一家独角兽的时候被原题面过当时只要求判断能否拆分答完后面试官轻描淡写补了一句那如果要求输出所有拆分方案呢直接把我干沉默了。后来我把这一题的所有相关解法、优化思路和变种全部捋了一遍才发现这道中等题里藏着的门道比想象中多得多。这道题题号是139在LeetCode上被标记为Medium但它几乎是最典型的字符串前缀动态规划题跟后面的分割回文串、编辑距离、正则表达式匹配都有共通之处。这篇文章我会从题面拆解开始逐步讲到DP状态定义、代码实现、剪枝优化、记忆化搜索再到面试中那些防不胜防的追问全程带上我踩过的坑和实测数据给正在刷热题100的朋友一个完整的参考。1. 先读透题从题面到动态规划的直觉1.1 题面回顾与真正的限制条件题目描述很简短给你一个字符串s和一个字符串列表wordDict作为字典判断能否利用字典中出现的单词拼接出s。比如s leetcodewordDict [leet, code]输出trues applepenapplewordDict [apple, pen]输出trues catsandogwordDict [cats, dog, sand, and, cat]输出false。这里有几个容易被忽略的关键点。第一拼接意味着单词出现的顺序必须和s中的字符顺序一致不能打乱也不能跳过字符。第二题目特别说明不要求字典中出现的单词全部都使用——也就是说字典里可以有多余的单词你只管挑能用上的。第三也是最容易被忽略的字典中的单词可以重复使用这点很关键很多人第一次做这题的时候会不自觉地当成每个单词只能用一次来思考结果卡在s aaaaaaawordDict [a, aa]这类用例上。另外要留个心眼的是字典中可能出现重复单词虽然不影响判断但最好去重避免无意义的重复查询。字符串s的长度在原始题目中最大到 300字典大小wordDict.length最大到 1000这个数据规模决定了我们需要一个能处理O(n^2)级别的方案暴力递归是行不通的。1.2 朴素回溯思路是对的但指数爆炸第一次接触这题最直觉的思路是从开头字符开始尝试切分。比如s leetcode先看l是否在字典中不在再看le不在直到leet在字典中于是把leet切出来继续对code做同样的判断——这种思路本质上是一个递归回溯。代码写出来大概是这样def wordBreak(s, wordDict): word_set set(wordDict) def backtrack(start): if start len(s): return True for end in range(start 1, len(s) 1): if s[start:end] in word_set and backtrack(end): return True return False return backtrack(0)这个写法放在答案里没问题但放进实际运行就会超时。为什么因为同一个子问题会被反复计算。举个极端例子s aaaaabwordDict [a, aa, aaa, aaaa, aaaaa]。从位置 0 开始你可以切出a、aa、aaa等多个合法单词每种切法都会走到位置 1、2、3 这些地方而位置 3 之后的剩余部分会被递归无数次。这个递归树的分支规模有多大最坏情况下每个位置都能被当作切分点递归树节点数接近2^n。字符串长度到 30 就已经非常吃力更不用说题目的 300。所以这道题的直觉和递推关系是对的但缺一个记笔记的环节——这正是动态规划或者记忆化搜索要解决的。1.3 为什么这道题具有最优子结构判断s[0:i]能否被拆分它依赖的是更短前缀s[0:j]的拆分结果如果s[0:j]能被拆分并且s[j:i]恰好是字典中的一个单词那整个s[0:i]就能被拆分这里j是任意小于i的位置。这个关系实际上说明了一个核心性质一个前缀能否被字典拼接出来这个问题可以降解到更短前缀的相同问题上去。每个前缀只需要存一个布尔值不需要关注具体的拆分方案。这种大问题的答案由子问题的答案组合而来的结构就是典型的最优子结构而同一个子问题被反复求解又对应了重叠子问题。两个动态规划的判定特征都在了直接上DP。这里还有一个很多人初学时的困惑我怎么知道该用DP而不是贪心一个很明显的信号是这道题没法用一个简单的规则从头到尾扫一遍搞定。你没法确定当前这个单词该切多长因为后面的字符会影响前面的选择比如s aaaaab里切aaaa和切a对后面的影响完全不同。当一个决策需要回头修改时DP就比贪心可靠得多。2. 动态规划解法状态的魔法2.1 状态定义dp[i] 到底是什么动态规划的第一步永远是想清楚状态。对于这道题定义dp[i] 表示字符串 s 的前 i 个字符即s[0:i]能否被字典中的单词拆分成功。dp[0] 表示空前缀默认是可以拆分的因为不需要任何单词就满足了拼接出空串的条件它是整个递推的基准。dp[i] 初始化为False表示默认不可拆分只有找到可行的切分才更新为True。这里最容易出错的点是dp[i]对应的是前 i 个字符而不是第 i 个字符所以数组长度是n 1而不是n。我见过很多初学者在这里栽跟头把dp[i]当成s[i]结尾的子串导致初始化、遍历范围全错位。记住一个技巧——动态规划数组的下标代表的是长度不是位置。长度是i的前缀在字符串中对应的切片是s[0:i]它是左闭右开的不包含s[i]本身。2.2 状态转移方程从短前缀推向长前缀有了dp[i]的定义转移方程可以这样写dp[i] true 当且仅当 存在一个 j0 j i使得 dp[j] true 且 s[j:i] 在 wordDict 中翻译成人话就是如果前 j 个字符已经能被拼出来并且从 j 到 i 的这一段恰好是一个字典单词那前 i 个字符也就拼出来了。这个过程相当于在枚举最后一个单词的起始位置。实现时的枚举顺序是从小到大遍历i目标前缀长度内层遍历j最后一个单词的起始下标。内层一旦找到一个合法的j直接break因为dp[i]已经是true了继续找下去没有意义。这个提前跳出不只是小优化在数据量大时能让实际运行时间肉眼可见地下降。还要注意一个判断条件的书写顺序if dp[j] and s[j:i] in word_set:这里把dp[j]放在前面很有讲究。and是短路运算如果dp[j]是FalsePython 不会执行后面的s[j:i] in word_set也就避免了无意义的字符串切片和哈希查询。千万别写成if s[j:i] in word_set and dp[j]:虽然逻辑上没区别但每个j都会做一次切片和查字典白白浪费大量时间。在面向性能的编程题里这种小细节积累起来就是天壤之别。2.3 复杂度分析不能只看表面基础的DP版本枚举i从 1 到 n内层枚举j从 0 到 i-1看上去是O(n^2)的时间复杂度。但这里有个隐藏成本每次判断s[j:i] in word_set时Python 的切片操作s[j:i]会新建一个子串这个操作本身的代价是O(i-j)即子串的长度。所以严格来说最坏情况下的时间复杂度是O(n^3)如果字典足够大每个子串都要被切出来。我见过一些网上博客直接写O(n^2)严格来说不严谨。不过在面试场景里如果只是口头分析说O(n^2)是普遍接受的说法如果你能主动补充一句严格讲还有子串构造的开销所以最坏可以到O(n^3)但实践中用哈希查字典通常是平均 O(1) 的反而会给面试官留下严谨的好印象。空间复杂度是O(n)存储 dp 数组。此外用于查字典的哈希集合占O(m*L)m是字典单词数量L是最长单词长度。在 LeetCode 给定的数据范围内n300这个复杂度完全够用但优化版本依然值得一提——面试官几乎一定会追问能不能再优化。2.4 代码实现Python 和 C 双版本Python 版本from typing import List class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: word_set set(wordDict) n len(s) dp [False] * (n 1) dp[0] True for i in range(1, n 1): for j in range(i): if dp[j] and s[j:i] in word_set: dp[i] True break return dp[n]C 版本class Solution { public: bool wordBreak(string s, vectorstring wordDict) { unordered_setstring wordSet(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] wordSet.count(s.substr(j, i - j))) { dp[i] true; break; } } } return dp[n]; } };两个版本核心逻辑一模一样只是一个用切片一个用substr。在 C 中s.substr(j, i - j)的第二个参数是子串长度不是结束位置这里特别容易手误写成substr(j, i)一写错就是典型的运行时错误。3. 优化与变种思路打开3.1 剪枝优化按最长单词长度限制枚举范围基础版本的内循环枚举了所有j i但仔细想一下如果一个子串s[j:i]的长度超过了字典中最长单词的长度那它in word_set的结果必然是False这一轮枚举就是白费的。于是我们可以预计算字典中最长单词的长度max_len在内层循环中j只需要从max(0, i - max_len)枚举到i - 1就足够了class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: word_set set(wordDict) max_len max(len(w) for w in wordDict) n len(s) dp [False] * (n 1) dp[0] True for i in range(1, n 1): start max(0, i - max_len) for j in range(start, i): if dp[j] and s[j:i] in word_set: dp[i] True break return dp[n]这个优化的时间复杂度从O(n^2)降到了O(n * max_len)在最长单词比较短的情况下效果立竿见影。我实测过n 300、wordDict中单词平均长度为 4 5 的场景剪枝版运行时间比基础版快一倍还多。在s非常长、字典单词都很短的场景下这个优化几乎是必须的。3.2 记忆化搜索换一种思考角度DFS memo 本质上是把同一个关系用递归的方式表达。定义dfs(start)表示从s[start:]这一段能否被字典拆分。class Solution: def wordBreak(self, s: str, wordDict: List[str]) - bool: word_set set(wordDict) memo {} 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)和DP版本相比记忆化搜索的优点是只计算实际需要求解的子状态。如果某个前缀根本不会被任何合法的切分路径走到它就不会被递归探到。而 DP 是从短到长把所有前缀都推了一遍哪怕有些前缀显然不可行也照样计算。两种方法时间复杂度的量级相同但常数上有差别。在面试中如果面试官问你能不能用别的方法做记忆化搜索是一个很好的回答。它和 DP 的递推关系一一对应只是方向相反——一个是从前往后推一个是从后往前回溯并记录。3.3 进阶变种LeetCode 140 输出所有拆分方案我特别提一下 LeetCode 140单词拆分 II因为面试追问能输出所有方案吗几乎是必考题。这题在 139 的基础上加了要求不仅判断能否拆分还要返回所有可能的句子。例如s catsanddogwordDict [cat, cats, and, sand, dog]需要返回[cats and dog, cat sand dog]。思路是先用和 139 一样的dp或记忆化搜索判断可行性同时记录每个位置的所有合法前驱切分点然后回溯拼接出完整方案。如果dp[n]本身就是false直接返回空列表连回溯都不需要做——这是很多第一次写 140 的人忽略的剪枝没有这个前置判断会浪费大量递归。写回溯的时候要注意路径拼接的开销。每次递归往下传的是一个字符串列表到叶子节点再 .join(path)而不是每个节点都拼接字符串再传下去。这个经验是从实际提交中总结出来的——如果你在递归过程中反复做字符串拼接会产生大量临时对象提交时很容易 TLE。3.4 什么时候考虑 Trie 树哈希集合in操作平均是 O(1)那为什么有些场景要用 Trie因为哈希集合只能回答整个单词是否在字典里回答不了当前前缀能匹配哪些单词这类查询。在 139 这道题里我们只需要判断s[j:i]是不是完整单词哈希集合足够了。但如果做 140 这种需要枚举所有匹配单词的题或者wordDict特别大、单词前缀重合度高用 Trie 可以避免反复做子串切片沿着 Trie 节点一路向下匹配即可。不过这不是这道题的重点——面试时先讲清楚 DP再把优化当作加分项提一嘴别一上来就上重型结构。4. 实操实录从手推状态表到 AC4.1 手推一遍以 leetcode 为例为了让大家彻底搞懂递归关系我手推一遍。s leetcoden 8。初始化dp [True, False, False, False, False, False, False, False, False]。i 1s[0:1] l不在字典dp[1] 保持 False。i 2j 0s[0:2] le不在字典j 1dp[1] 为 False跳过。dp[2] False。i 3s[0:3] lee不在字典dp[1] 为 False跳过dp[2] 为 False跳过。dp[3] False。i 4j 0s[0:4] leet在字典中且 dp[0] True所以 dp[4] Truebreak。i 5j 0s[0:5] leetc 不在字典j 1dp[1] False…… j 4dp[4] True检查 s[4:5] c 不在字典j 5 不能取。dp[5] False。i 6, i 7 类似全是 False。i 8j 0 到 3 都不行当 j 4dp[4] Trues[4:8] code在字典中所以 dp[8] True。最终返回 dp[8] True。整个过程可以看到dp[4] 这个中间状态被反复引用这正是前缀状态的价值。如果不用 DP位置 4 之后的递归会被重复计算无数次。4.2 边界条件与测试用例设计刷题和工程一样边界用例比正常用例更能检验代码的鲁棒性。我整理了几个高频测试用例强烈建议提交前逐条跑一遍用例输入预期输出考察点空字符串s , wordDict [a]truedp[0] 的基准语义单个字符匹配s a, wordDict [a]true最小合法切分字典含多余词s a, wordDict [a, b]true不要求全部使用单词可重复使用s aaaa, wordDict [a, aa]true重复使用约束无法完整拆分s catsandog, wordDict [cats,dog,sand,and,cat]false贪心无法解决的经典反例特别说一下最后一个用例。如果你尝试贪心从左往右匹配先取cats剩下andog匹配and剩下og失败。然后你可能想着回溯重新取cat再试如果你写的是普通贪心这个用例就挂了。而 DP 会枚举所有切分点不会漏掉任何一种组合。还有一个容易踩的坑是字典中最长单词比s还长。比如s abcwordDict [abcd]如果不用max_len剪枝内层照样枚举所有 j得到的全是 False结果没问题但效率低。加了max_len剪枝后start max(0, i - max_len)可能会小于 0必须用max()兜底否则索引报错。4.3 我踩过的坑三件真实发生的惨案第一个坑是in的对象。一开始我偷懒直接用wordDict这个 list 做成员判断if s[j:i] in wordDict:list 的in是线性扫描时间复杂度 O(L)而 Python 里 set 的in是哈希查询平均 O(1)。当时我用了一个 200 长度的字符串加上 1000 个单词的字典提交直接超时。换成 set 之后瞬间通过。在 C 里同理用unordered_set而不是vector。第二个坑是dp的更新时机。写过一版把dp[i] True放在循环外面的导致 dp[i] 只记录了最后一次循环的 j 结果根本不对。这类 bug 就是你盯着代码看半天也看不出来的那种——只有当你用leetcode这个用例手推一遍才会发现 dp[4] 居然是 False。第三个坑是切片边界。s[j:i]是左闭右开的不包含第 i 个字符。有人会把目标前缀的理解弄混写成s[j:i1]结果不仅推导错误还会出现索引越界。这种问题建议多写几个单字符用例验证比如s an 1当 i 1 时j 只能取 0切片应该是s[0:1]而不是s[0:2]。4.4 性能实测优化前 vs 优化后我用一个比较极端的测试场景来看优化效果s长度为 300全部由小写字母组成字典包含从a到abcdefghijklmnopqrstuvwxyz的一部分短单词最长单词长度为 10。在同一台机器上分别跑基础版和剪枝版基础版枚举所有 j耗时约 0.8 ms。剪枝版限制 j 的起点耗时约 0.3 ms。这是 n300 的 LeetCode 官方数据规模差距还不算夸张。但如果你把s换成 5000 长度的自定义用例基础版会膨胀到几百毫秒而剪枝版稳定在几十毫秒以内。对于想优化到极致的场景还有一个思路是倒过来遍历字典——不是枚举所有 j而是枚举所有字典单词检查它是否匹配s的某个后缀。这在字典小、字符串大的场景下效果更好。不过这个方案在 LeetCode 的标准数据下优势不大面试时作为进阶思路提一嘴即可不必真的去写。5. 面试追问与复盘这道题的真正价值5.1 面试官的四个典型追问方向这道题在面试中出现频率极高而且基本不会止步于写出正确代码。我整理了四个被问过或者看到过的追问方向并给出应对思路追问一能不能优化直接答三件事——用set替代list做查询、用max_len剪枝限制枚举范围、如果能提前break就赶紧break。这三板斧足够应付大多数面试官的继续追问。追问二能不能输出所有方案这是 LeetCode 140 的考点。思路是先用 DP 判断可行性若有解则回溯记录所有路径。回溯时注意先剪枝dp[n]为 False 就直接返回空再匹配字典单词最后倒序拼接。追问三如果字典非常大怎么办考虑用 Trie 做前缀匹配或者提前按单词长度分组比如用一个length - list of words的映射表只遍历可能匹配的单词组。这个方案在面试中很加分因为展示了你对数据结构应用场景的理解。追问四如果 s 的长度到了 10^5 怎么办O(n^2) 肯定挂。这类问题一般没有再简单的解法需要往 AC 自动机或者后缀自动机方向想了但一般不会问到这么深差点到分布式这类离谱追问就不必较真了。5.2 由单词拆分延伸出去的一整类DP单词拆分不是孤立的一道题。我在刷题时发现给定一个字符串判断能否由字典中的元素拼接/分割这类问题有着惊人的一致性核心都是前缀状态LeetCode 131 分割回文串给你一个字符串 s返回所有可能的回文子串分割方案。同样枚举切分点只不过判断条件从子串在字典中变成了子串是回文。LeetCode 132 分割回文串 II返回最少分割次数。多了一个最小化的维度从布尔 DP 变成了取 min 的 DP。LeetCode 72 编辑距离本质是二维前缀匹配 DP思路的源头也有前缀状态的影子。当你刷到这些题时如果能有意识地把它们归入前缀 DP这个大类就会发现每道新题的解题成本都在下降——你不是在学一道题而是在学一类题。这比单纯背题解有意义得多。5.3 我的复盘心得状态定义是动态规划的灵魂说句掏心窝的话刷了这么多年题我最大的感触是动态规划题目看起来千变万化但九成以上的难点都集中在同一个地方——状态定义。单词拆分这道题如果一开始就把dp[i]想明白是前 i 个字符能否被拆分整个代码几乎是水到渠成地写出来反过来如果状态定义错了不管你怎么调整循环、怎么优化剪枝都是错上加错。我见过有些人刷题时特别爱背模板什么背包九讲、股票六题张口就来但一遇到新题就垮。原因就是背的是代码痕迹不是状态设计思路。我自己的习惯是拿到一道题先在草稿纸上画一个状态转移的例子比如强制自己写dp[4]是怎么由dp[0]和dp[3]叠出来的想清楚了再动键盘。这个习惯帮我避免了很多无效提交。写在最后的实操小技巧最后分享一个我自己实际调试时常用的技巧在写好 DP 代码之后不要急着提交先构造一个半对半错的用例手推一遍状态表。比如s abcdewordDict [ab, bc, cd, de]注意bc和de虽然都在字典里但由于ab切出来后剩下的cde无法被字典中的词覆盖最终结果应该是 False。如果你用这个用例手推会发现 dp[2] 为 True但 dp[5] 始终是 False。这个练习对加深状态递推的理解特别有帮助而且能在 30 秒内暴露绝大多数边界和索引错误。另外LeetCode 官方的热题 100 里这道题的序号编排在第 82 位左右正好是动态规划分组的中间位置。如果你刚好刷到这里觉得有点吃力不用灰心——这恰恰说明你在接触前缀 DP 思想的拐点。把 139 题吃透后面 140、131、132 都会顺很多祝刷题顺利。
返回列表