
这道题在力扣热题100里排在第82位是动态规划专题里非常经典的一道入门题。很多人第一次看到“单词拆分”这个标题会觉得是要处理什么天然的文本分割问题但实际上它考察的就是一个很朴素的思路一个字符串能不能被拆成若干个字典里出现过的单词。题目本身不长但解法背后的思路延展性很强刷透这一题动态规划的“状态设计”这一课基本就算补上了。这类题目在面试里出现频率也高尤其是偏算法岗的现场面因为它在暴力递归、记忆化搜索、动态规划、广搜、甚至字典树优化这几个层级上都能做文章非常适合考察候选人从朴素解法到最优解法的推导能力。这篇文章我打算把这道题从题目理解、状态设计、代码实现到各种优化思路完整过一遍顺便把我在提交过程中踩过的坑和排查经验也整理出来。不管你是刚开始刷热题100还是复习动态规划想找一道题打通思路这篇都对你胃口。1. 题目理解与核心思路拆解先看题面。给你一个字符串s和一个单词字典wordDict判断s是否可以被拆分成一个或多个字典中出现的单词拆的时候单词可以重复使用。例如s leetcode字典[leet, code]返回true因为可以拆成leet和code。1.1 这个题到底在问什么把题目翻译成人话给你一把长度固定的积木字典里的单词你能不能正好用这些积木拼出目标图形字符串s积木可以重复用拼的时候必须每一段都刚好是字典里的某个词不能有残留也不能超出长度。这里有几个关键点容易被忽略我先拎出来字典里的单词在判断时是可重复使用的所以它不是背包问题里那种每个物品只能用一次的限制反而是完全背包的语境。拆分出来的每个片段必须是字典中出现的单词也就是说我们要对s做切割切割点可以任意选但切出来的每一段都要能在字典里找到。不需要输出具体怎么拆只需要判断能不能拆。所以这个题本质上是一个可行性问题而不是方案计数或方案输出问题。正是最后这一点决定了它最适合用动态规划来做。你要做的是把一个大问题分解成互相重叠的子问题然后每个子问题只记录一个布尔值这个前缀能不能拆。1.2 为什么暴力回溯会超时刷题第一步要养成习惯先用最朴素的思路想通再考虑怎么优化。单词拆分的朴素思路是回溯即从字符串开头出发枚举所有可能的单词作为第一段如果匹配上就递归检查剩余部分。这么说起来似乎可行实际一写代码就会发现灾难。以s aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab字典为[a, aa, aaa, aaaa, aaaaa]这种输入为例递归树以指数级膨胀大部分路径都在重复计算同一个后缀能不能拆。更致命的是如果不存在可行拆法回溯会遍历近乎所有组合才会终止直接原地爆炸。所以这类“大问题能否拆成子问题且子问题有重叠”的迹象一出现第一反应就应该是动态规划。1.3 动态规划是这道题的唯一正解吗可以明确地回答对这道题来说动态规划包括它的记忆化搜索变体就是最标准的解法。LeetCode官方题解的主流出路也是动态规划。不仅如此这道题还有两个有趣的变体视角把字符位置看作图中的节点如果s[j:i]是字典中的单词就存在一条从j到i的边。问题转化成了从节点0能否到达节点n于是可以用BFS求解。如果把字典建成字典树Trie可以在内层循环中利用前缀树加速匹配避免对每个j都做一遍子串提取和哈希查找。这两种视角各有适用场景稍后我会展开讲。但无论哪个视角底层状态设计的逻辑都逃不开动态规划那一套核心思想。所以先吃透动态规划其他都是锦上添花。2. 动态规划状态设计与转移方程推导这一节是整道题的关键也是面试官最愿意深挖的地方。如果状态定义说清楚了转移方程写不出来都是扣分如果转移方程写出来了但不知道为什么要这么设计面试追问几轮就会露馅。2.1 状态定义dp[i]到底表示什么定义dp[i]表示字符串s的前i个字符即子串s[0:i]能否被字典中的单词拆分能则为True不能则为False。这里一定要建立索引对应的概念。很多初学者在这里容易搞混dp[i]对应的不是字符s[i]而是从开头数起的前i个字符。比如s leetcode那么dp[0] True表示空前缀天然可以拆分。dp[1]对应l这个前缀能否拆分。dp[4]对应leet能否拆分。dp[8]对应leetcode能否拆分。最终答案就是dp[n]其中n len(s)。2.2 状态转移方程的直观推导假设我们已经知道了所有dp[0]到dp[i-1]的值现在要算dp[i]。应该怎么思考只需要做一件事枚举最后一个单词的起始位置j。如果s[j:i]这个子串在字典里并且dp[j] True也就是说j位置之前的部分能拆成合法单词那么把两个部分拼起来s[0:i]就一定能拆。于是dp[i] True 当且仅当存在一个 j (0 j i)使得 dp[j] True 且 s[j:i] in wordDict如果不存在这样的jdp[i]就是False。这个转移方程的巧妙之处在于它把“前i个字符能不能拆”这个大问题转化成了“前j个字符能不能拆”这个更小的子问题再加上一次O(1)的哈希查找。子问题的规模逐步缩小最终落在dp[0] True这个已知的初始条件上形成完整的递推链。2.3 枚举顺序与复杂度估计双层循环的结构几乎是确定的外层循环枚举i从1到n内层循环枚举j从0到i-1。每个i都要扫描它前面所有可能的分割点。时间复杂度最直接的分析是O(n^2)次子串判断每次判断是一个字符串切片加哈希查找。Python里切片本身是O(k)的复制操作k为切片长度所以严格意义上总复杂度是O(n^3)。但工程上因为k通常远小于n实际表现不会那么差一般官方题解的复杂度分析也按O(n^2)来定性描述LeetCode的测试数据下这个写法的运行时间是可接受的。空间复杂度是O(n)只用一个一维数组存布尔值。这个开销在所有动态规划题目里算非常轻的了。2.4 为什么dp[0]必须是Truedp[0]代表空前缀它不是真实存在的字符串片段但它是整个递推的基石。如果不把它设为True那么当某个单词s[0:i]本身就在字典里时dp[i]永远不会被标记为True因为dp[0]是False就算子串匹配成功也满足不了dp[j] True的条件。空串能不能拆这道题的语义里不关心这个问题它只是一个为了让转移方程自洽的辅助值。面试时被问到直接回答“它是初始条件用来表示字符串起点之前的空状态可以被视为已拆分即可”不要支支吾吾。3. 核心解法实现与代码详解理论讲完直接上代码。我以Python为例写一版最接近标准动态规划思路的实现然后逐行讲清楚每个细节。def wordBreak(s: str, wordDict: list[str]) - bool: # 把字典转成哈希集合查找效率O(1) word_set set(wordDict) n len(s) # dp[i] 表示 s[0:i] 能否被拆分 dp [False] * (n 1) dp[0] True for i in range(1, n 1): for j in range(i): # 如果前缀 s[0:j] 能拆分且 s[j:i] 在字典中 if dp[j] and s[j:i] in word_set: dp[i] True # 只要找到一个合法拆法当前 i 就可以提前结束 break return dp[n]3.1 逐行拆解为什么要这么做第一处值得注意的细节是word_set set(wordDict)。这一步很多人会漏直接拿wordDict这个列表去判断s[j:i] in wordDict。列表的in操作是线性扫描最坏情况O(k)其中k是字典长度。套在双层循环里整体复杂度直接再乘一个k部分测试用例会因此超时。转成哈希集合后判断一个单词是否存在的均摊复杂度降为O(1)这是整个优化链条里最基础也是性价比最高的一步。第二处是dp [False] * (n 1)。数组长度是n 1而不是n因为dp索引对应的是前缀长度从0到n共n 1个状态。漏掉dp[0]或者忘了初始化它都会让答案出错。第三处是break。内层循环只要找到一个j满足条件dp[i]就已经是True了后面再枚举更大的j没有意义。这个break对整体性能的贡献在极端情况下非常可观比如aaaaaaaaaaaaab这种失败场景它不能早停但如果是leetcode这种很快命中的场景它能在第一个合法分割点处就收工。这一步属于白送的性能优化不加白不加。3.2 边界条件推演手动跑一个完整例子拿s leetcodewordDict [leet, code]走一遍流程初始化dp[0] True其余为False。i 1子串s[0:1] l不在字典dp[1] False。i 2考察s[0:2] le不在字典dp[2] False。i 3s[0:3] lee不在字典dp[3] False。i 4j 0时s[0:4] leet在字典且dp[0] True所以dp[4] Truebreak。i 5j从0到4依次尝试s[0:5] leetc、s[1:5] eetc均不在字典……直到j 5结束没找到dp[5] False。i 6类似所有候选子串都不在字典dp[6] False。i 7都失败dp[7] False。i 8j 0时s[0:8] leetcode不在字典j 4时dp[4] True且s[4:8] code在字典于是dp[8] True。最终dp[8] True输出正确。这个推演过程建议大家自己在纸上画一遍画完就理解了为什么dp[i]只关心“能否”而不关心“怎么拆”——中间怎么拆的细节在状态里已经被丢掉了只留下一个布尔值继续向上传递。3.3 复杂度的工程视角分析理论复制度上面已经说过了这里补充一点工程视角的观察。LeetCode上有一些刻意构造的极端用例比如字典中大量超短单词、目标串巨长且由相同字符重复组成。在这种输入下内层循环会跑满实际耗时可能到数百毫秒。在本地调试时如果用Python跑极端用例建议开启PyPy或C重写来验证性能差异语言层面的常数差距在这种O(n^2)级别的算法上会被放大。4. 常见问题与排查技巧实录这道题刷的人多踩坑记录也丰富。我把提交过程中最常见的几类问题和对应的排查思路整理成一个速查表方便你对照自查。问题现象可能原因排查与解决方案提交超时本地简单样例正常内层循环没有break或字典没转哈希集合增加break确认用的是set而非list小规模样例输出False但预期True忘记初始化dp[0] True检查初始化逻辑dp[0]永远是递推基石结果不稳定的随机错误切片索引越界或j从1开始枚举写出dp数组推演一个小样例确认j从0开始答案差一位的边界错误dp[i]与s[i]混淆索引理解偏差回到定义画一遍dp数组和字符串下标的对应关系内存占用偏高尝试了二维dp或存储了所有拆分方案本题只需一维布尔数组二维纯属多余使用递归记忆化但爆栈递归深度接近字符串长度改为迭代式动态规划彻底避免栈溢出风险4.1 经典错误字典是列表导致超时这是我最早刷这道题的真实经历。第一次提交时很自信把wordDict原封不动用于判断结果一个中等规模测试用例就超时了。当时还没意识到列表in和集合in的性能差距有多大后来把wordDict转成set再提交运行时间从超时降到了几十毫秒。这个坑在真实业务里也有对应场景数据量一大查找数据结构的选择直接决定接口能不能扛住压力。刷题时养成“查找先转哈希”的习惯对工作里的代码评审也很有帮助。4.2 经典错误break丢了导致性能退化再看一个典型代码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 # 没有 breakcontinue 继续跑剩下的 j没有break的情况下比如catcatcat这种一旦j 0命中就得停的场景代码还会继续扫描j 1到j i - 1白白做了大量无效子串判断。在n 300、字典很小的情况下这个差异可能只有几毫秒但输入长度上千以后差别会被放大。从代码规范的角度说找到答案后立即退出是一个通用原则。动态规划里只要当前状态已经被确定为True后续枚举只会浪费算力。4.3 更隐蔽的坑用切片判断时的字符串复制开销Python里s[j:i]是一次子串复制会额外占用内存。在双层循环里这个复制次数是O(n^2)量级的虽然单次复制长度有限但累计开销不容忽视。想进一步优化可以改用startswith方法或者直接对s[j:]调用startswith判断前缀单词从数据结构层面减少复制。写一个优化版def wordBreak_optimized(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 w in word_set: lw len(w) if i lw and dp[i - lw] and s[i - lw:i] w: dp[i] True break return dp[n]这个版本的思路反过来不再枚举分割位置j而是枚举字典中的每个单词看它能否刚好接在某个可拆分前缀的末尾。它的好处是循环次数和字典大小相关如果字典很小而字符串很长这个版本效率明显更高。但要注意它依赖于len(w)精确匹配如果字典里有很多相同长度的词依然要逐一尝试。4.4 记忆化搜索与动态规划同一种思路的两种写法除了自底向上的迭代式动态规划自顶向下的递归加记忆化也能解决这道题。核心思路是定义递归函数dfs(i)表示s[0:i]能否拆分然后枚举分割点遇到已计算的状态直接返回缓存结果。from functools import lru_cache def wordBreak_memo(s: str, wordDict: list[str]) - bool: word_set set(wordDict) n len(s) lru_cache(None) def dfs(i): if i 0: return True for j in range(i): if dfs(j) and s[j:i] in word_set: return True return False return dfs(n)递归写法在思路上更接近人类直觉——“前i个字符能不能拆取决于前面某个j能不能拆加上最后一段在不在字典里”。代价是有递归调用开销和栈溢出的风险尽管对这道题的长度而言风险不大。迭代式动态规划没有这个问题所以我个人推荐以迭代式为主、递归式用于理解思路。面试时如果被问到两种写法的区别能说出“递归适合描述状态、迭代适合实际执行”就算过关。4.5 最长单词长度剪枝一个容易被忽略的小优化在枚举分割点j时j越小s[j:i]越长。而字典中的单词长度通常远小于字符串长度因此我们可以提前记录字典中单词的最大长度max_len。枚举分割点时如果i - j max_len说明当前子串长度已经超过字典中任何单词的长度s[j:i]必然不在字典中直接跳过。这个剪枝能把内层循环从O(i)降到O(max_len)当字典里单词普遍较短时效果明显。def wordBreak_with_pruning(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): # 只枚举可能作为分割点的 j for j in range(max(0, i - max_len), i): if dp[j] and s[j:i] in word_set: dp[i] True break return dp[n]注意这里j的范围变成了[max(0, i - max_len), i)意思是最远的前缀长度不能短到让剩余部分超过max_len。这是一个数学上严格成立的剪枝不改变结果只缩小搜索空间。我在实际提交中这个版本在长字符串用例下的运行时间是最短的。5. 题目变形与面试延伸刷题不能只满足于过掉一道题要有意识地把它放到更大的知识网络里看。单词拆分这题和好几个经典题都有联系面试官也喜欢在这附近追问。5.1 从动态规划视角看这题和完全背包什么关系如果将字典中的单词看作物品字符串s看作背包容量那么每个单词可以重复使用且我们需要恰好填满背包。这对应的是完全背包的判定问题。完全背包的经典转移是dp[i] | dp[i - weight[j]]这题里weight[j]换成了单词长度dp[i] | dp[i - len(w)]且要求对应子串匹配。想通这层联系你就把“单词拆分”和背包问题统一到了一个知识框架里遇到类似的字符串拼接判定题就能举一反三。5.2 从图论视角看这题等价于路径可达把字符串的每个前缀位置抽象成图的节点0到n一共n1个节点如果s[j:i]在字典中就添加一条从节点j到节点i的有向边。那么题目要回答的就是是否存在一条从节点0到节点n的路径。这是一个标准的有向图可达性问题可以用BFS求解def wordBreak_bfs(s: str, wordDict: list[str]) - bool: word_set set(wordDict) n len(s) visited [False] * (n 1) queue [0] while queue: start queue.pop(0) if start n: return True for end in range(start 1, n 1): if not visited[end] and s[start:end] in word_set: visited[end] True queue.append(end) return FalseBFS的复杂度依然是O(n^2)但好处是不需要完整计算所有dp[i]找到一个合法路径就提前结束。在“能拆就立刻返回”的场景下BFS通常比动态规划更快。面试时如果先写BFS再被追问动态规划写法反而能展示你对同一问题的多角度理解。5.3 相关进阶题单词拆分II与拼接方案输出一道经典的追问是如果不仅要判断能否拆分还要输出所有可能的拆分方案LeetCode 140题该怎么处理方案输出意味着我们不能再只保留布尔值必须记录每个dp[i]是由哪些前置状态j转移来的。有两种常见做法在动态规划的过程中用一个pre[i]列表记录所有能使dp[i]为True的j最后从n回溯拼接出所有方案。直接用带记忆化的深度优先搜索从末尾往前找分割点递归收集所有组合。这两条路径都要在“单词拆分”的基础上增加一个维度——从判断是否存在变为穷举所有合法路径。复杂度也相应从O(n^2)上升到指数级因为方案数本身就可能是指数级的。5.4 字典树优化当字典很大时怎么加速匹配如果wordDict非常大大到哈希集合的内存都不太友好或者字符串长度很长用字典树Trie来加速前缀匹配会是一个精巧的优化方向。核心思路是在内层循环中不枚举所有j而是从当前位置i往前在Trie中逆向匹配字符一旦匹配失败立即停止。这样每个i的匹配开销从O(n)降到O(max_word_len)而且不需要做子串切片。不过说实话这道题在面试中要求手写Trie优化的概率不高更多是作为扩展知识点被提及。了解思路即可重点还是把基础动态规划写熟。6. 实操总结与个人经验刷这道题我前后写过三个版本。第一个版本用列表判断字典提交超时第二个版本改成哈希集合加break正常通过第三个版本加了最长单词长度剪枝在极端长字符串用例上耗时又降了一截。整个过程帮我加深了一个习惯先优化数据结构再优化算法流程最后再考虑算法层面的大改。单词拆分这道题在我看来是动态规划入门的“标准样板间”。状态定义清晰一维布尔数组转移方程直观枚举分割点拼接判断初始条件明确dp[0] True优化方向多样剪枝、BFS、字典树。把这题吃透后面做编辑距离、最长递增子序列、背包问题时会顺很多因为你会慢慢发现所有动态规划题目其实都在做同一件事用状态记录子问题的答案再用转移把子问题的答案拼成大问题的答案。最后分享一个小技巧这题写完以后不要急着看题解。自己把dp数组打出来用leetcode和leet, code这类小用例手动走一遍再换一个拆分失败的用例走一遍。这一步走完你对动态规划的“自底向上”四个字会有完全不一样的感觉。