📖 第76课:单词拆分

3 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第76课:单词拆分

模块:动态规划 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/wo… 前置知识:第71课(爬楼梯)、第75课(零钱兑换) 预计学习时间:30分钟


🎯 题目描述

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中的单词拼接出 s。字典中的单词可以重复使用,但拼接时不能有字符剩余或重叠。

示例:

输入:s = "leetcode", wordDict = ["leet", "code"]
输出:true
解释:"leetcode" 可以由 "leet""code" 拼接而成
输入:s = "applepenapple", wordDict = ["apple", "pen"]
输出:true
解释:"applepenapple" 可以由 "apple", "pen", "apple" 拼接而成
输入:s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出:false

约束条件:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • 字典中的单词可以重复使用
  • 所有字符串仅由小写英文字母组成

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
空字符串s="", wordDict=["a"]true空串认为可拆分
单个单词s="apple", wordDict=["apple"]true基本功能
重复使用s="aaaa", wordDict=["aa"]true单词可重复
前缀陷阱s="aaab", wordDict=["aa","aaa"]false贪心失败案例
无解情况s="catsandog", wordDict=["cats","dog","sand","and","cat"]false"sandog"无法拆分

💡 思路引导

生活化比喻

想象你在玩拼图游戏,有一串字母 "leetcode",手上有若干单词卡片["leet", "code"]可以无限次使用...

🐌 笨办法:从左到右尝试匹配。先试"leet"能匹配,剩下"code"继续试...如果遇到"applepenapple"就麻烦了,可能试"apple"、试"pen"、再试"apple",每次都要回溯重试,组合爆炸!

🚀 聪明办法:建一张"可拆分表",从左到右标记"前i个字符能否拆分"。比如标记"leet"(前4个字符)可拆分,那么看"code"能不能接上去。只要找到某个中间位置j,使得"前j个字符可拆分"且"j到i是个单词",那么"前i个字符"也可拆分!这样只需要扫一遍字符串。

关键洞察

字符串DP的核心:用 dp[i] 表示"前i个字符能否拆分",枚举所有可能的分割点!


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:字符串s + 字典wordDict
  • 输出:布尔值,能否完全拆分
  • 限制:字典中单词可重复使用,必须恰好拼接成s(不能有剩余)

Step 2:先想笨办法(回溯暴力)

从位置0开始,尝试所有可能的单词:

  • 如果s[0:len(word)] == word,递归判断剩余部分s[len(word):]
  • 如果所有单词都试完仍无解,返回False
def word_break_backtrack(s, wordDict):
    def dfs(start):
        if start == len(s): return True  # 全部匹配完
        for word in wordDict:
            if s[start:start+len(word)] == word:
                if dfs(start + len(word)):  # 递归尝试剩余部分
                    return True
        return False
    return dfs(0)
  • 时间复杂度:O(2^n) — 每个位置可能有多个单词匹配,递归树指数级
  • 瓶颈在哪:重复计算,比如s="aaaa",可能多次计算"从位置2开始能否拆分"

Step 3:瓶颈分析 → 优化方向

递归树中"从位置i开始能否拆分"被重复计算。

  • 核心问题:每个起始位置的结果被重复计算
  • 优化思路:用数组 dp[i] 记录"前i个字符能否拆分",从左到右填表

Step 4:选择武器

  • 选用:字符串DP
  • 理由:将大问题(整个字符串)拆成子问题(前i个字符),每个位置只判断一次

🔑 模式识别提示:当题目出现"字符串"+"能否完全匹配"+"可重复使用资源",优先考虑"字符串DP"


🔑 解法一:动态规划(自底向上)

思路

定义 dp[i] 表示前i个字符能否拆分。对于每个位置i,枚举所有可能的分割点j,如果 dp[j] 为真且 s[j:i] 是字典中的单词,则 dp[i] 为真。

图解过程

示例:s = "leetcode", wordDict = ["leet", "code"]

初始化 dp 数组(dp[i]表示前i个字符能否拆分):
dp = [True, False, False, False, False, False, False, False, False]
      0     1      2      3      4      5      6      7      8
      ""    l      le     lee    leet   leetc  leetco leetcod leetcode

遍历每个位置 i=1 to 8:

i=1("l"):
  尝试分割点j=0:s[0:1]="l" 不在字典,dp[1]=False

i=2("le"):
  尝试分割点j=0:s[0:2]="le" 不在字典,dp[2]=False

i=3("lee"):
  尝试分割点j=0:s[0:3]="lee" 不在字典,dp[3]=False

i=4("leet"):
  尝试分割点j=0:s[0:4]="leet" 在字典!dp[0]=True
  ✅ dp[4] = True

i=5("leetc"):
  尝试分割点j=0,1,2,3,4:都不满足条件,dp[5]=False

i=6("leetco"):
  尝试分割点j=0,1,2,3,4,5:都不满足,dp[6]=False

i=7("leetcod"):
  尝试分割点j=0,1,2,3,4,5,6:都不满足,dp[7]=False

i=8("leetcode"):
  尝试分割点j=4:dp[4]=True 且 s[4:8]="code" 在字典!
  ✅ dp[8] = True

最终 dp[8] = True,返回 true

Python代码

from typing import List


def word_break(s: str, wordDict: List[str]) -> bool:
    """
    解法一:动态规划(自底向上)
    思路:dp[i]表示前i个字符能否拆分,枚举分割点j
    """
    n = len(s)
    # 转为集合加速查找
    word_set = set(wordDict)

    # 初始化:dp[i]表示前i个字符能否拆分
    dp = [False] * (n + 1)
    dp[0] = True  # 空字符串认为可拆分

    # 遍历每个位置
    for i in range(1, n + 1):
        # 枚举所有可能的分割点j
        for j in range(i):
            # 如果前j个字符可拆分 且 [j, i)是字典中的单词
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break  # 找到一个即可,无需继续

    return dp[n]


# ✅ 测试
print(word_break("leetcode", ["leet", "code"]))           # 期望输出:True
print(word_break("applepenapple", ["apple", "pen"]))      # 期望输出:True
print(word_break("catsandog", ["cats","dog","sand","and","cat"]))  # 期望输出:False
print(word_break("aaaa", ["aa"]))                         # 期望输出:True (重复使用)

复杂度分析

  • 时间复杂度:O(n² × L) — n是字符串长度,L是单词平均长度。外层循环O(n),内层枚举分割点O(n),每次切片和查找O(L)
    • 具体地说:如果s长度n=300,单词平均长度L=10,大约需要 300 × 300 × 10 = 900000 次操作
  • 空间复杂度:O(n + m×L) — dp数组O(n),word_set存储m个单词共O(m×L)

优缺点

  • ✅ 逻辑清晰,易于理解
  • ✅ 时间复杂度已达最优(必须检查每个位置)
  • ⚠️ 切片操作 s[j:i] 有额外开销,可优化

🏆 解法二:优化DP(按单词长度枚举)(最优解)

优化思路

解法一中枚举所有分割点j效率不高。观察到字典中单词长度有限(最大20),可以反过来:对于每个位置i,只枚举字典中的单词,检查s是否以该单词结尾!

💡 关键想法:与其枚举所有分割点,不如直接尝试匹配字典中的单词,效率更高!

图解过程

示例:s = "leetcode", wordDict = ["leet", "code"]

初始化:dp = [True, False, ..., False]

i=4时:
  尝试单词"leet"(长度4):
    s[0:4]="leet" 匹配!且 dp[0]=True
    ✅ dp[4] = True

i=8时:
  尝试单词"code"(长度4):
    s[4:8]="code" 匹配!且 dp[4]=True
    ✅ dp[8] = True

只需要尝试字典中的单词,避免枚举所有分割点!

Python代码

from typing import List


def word_break_optimized(s: str, wordDict: List[str]) -> bool:
    """
    解法二:优化DP(按单词长度枚举) 🏆
    思路:对每个位置i,直接尝试匹配字典中的单词,而非枚举所有分割点
    """
    n = len(s)
    word_set = set(wordDict)

    dp = [False] * (n + 1)
    dp[0] = True

    for i in range(1, n + 1):
        # 直接尝试字典中的每个单词
        for word in word_set:
            word_len = len(word)
            # 如果单词长度不超过i,且s以该单词结尾
            if i >= word_len and dp[i - word_len] and s[i - word_len:i] == word:
                dp[i] = True
                break  # 找到一个即可

    return dp[n]


# ✅ 测试
print(word_break_optimized("leetcode", ["leet", "code"]))           # 期望输出:True
print(word_break_optimized("applepenapple", ["apple", "pen"]))      # 期望输出:True
print(word_break_optimized("catsandog", ["cats","dog","sand","and","cat"]))  # 期望输出:False

复杂度分析

  • 时间复杂度:O(n × m × L) — n是字符串长度,m是字典单词数,L是单词平均长度。相比解法一的O(n²×L),当m << n时更快!
    • 具体地说:如果n=300,m=10,L=10,只需 300 × 10 × 10 = 30000 次操作(比解法一快30倍!)
  • 空间复杂度:O(n + m×L) — 相同

⚡ 解法三:Trie树优化(进阶)

优化思路

将字典构建成Trie树,对于每个位置i,从i向右匹配Trie,找到所有可能的单词结尾。避免了字符串切片和集合查找。

Python代码

from typing import List


class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False


def word_break_trie(s: str, wordDict: List[str]) -> bool:
    """
    解法三:Trie树优化
    思路:用Trie树存储字典,对每个位置向右匹配
    """
    # 构建Trie树
    root = TrieNode()
    for word in wordDict:
        node = root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_end = True

    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True

    for i in range(n):
        if not dp[i]:
            continue  # 如果前i个字符无法拆分,跳过
        # 从位置i开始向右匹配Trie
        node = root
        for j in range(i, n):
            ch = s[j]
            if ch not in node.children:
                break  # 无法继续匹配
            node = node.children[ch]
            if node.is_end:  # 找到一个单词
                dp[j + 1] = True

    return dp[n]


# ✅ 测试
print(word_break_trie("leetcode", ["leet", "code"]))           # 期望输出:True
print(word_break_trie("applepenapple", ["apple", "pen"]))      # 期望输出:True

复杂度分析

  • 时间复杂度:O(n² + m×L) — 构建Trie O(m×L),DP部分每个位置最多匹配n个字符
  • 空间复杂度:O(m×L×26) — Trie树空间

🐍 Pythonic 写法

利用 any() 和生成器表达式:

def word_break_pythonic(s: str, wordDict: List[str]) -> bool:
    word_set = set(wordDict)
    dp = [True] + [False] * len(s)

    for i in range(1, len(s) + 1):
        dp[i] = any(dp[j] and s[j:i] in word_set for j in range(i))

    return dp[-1]

解释:

  • any(...) 只要有一个条件为真就返回True,比手动循环更简洁
  • 列表推导 [True] + [False] * len(s) 初始化dp数组

⚠️ 面试建议:先写清晰的循环版本展示思路,再提Pythonic写法展示语言功底。


📊 解法对比

维度解法一:枚举分割点🏆 解法二:枚举单词(最优)解法三:Trie树
时间复杂度O(n²×L)O(n×m×L) ← m<<n时更快O(n² + m×L)
空间复杂度O(n + m×L)O(n + m×L)O(m×L×26)
代码难度简单简单 ← 逻辑更清晰较难
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景通用字典单词数较少字典巨大且查询频繁

为什么解法二是最优解:

  • 当字典单词数m远小于字符串长度n时,时间复杂度从O(n²×L)优化到O(n×m×L),提升巨大
  • 代码逻辑更清晰:直接尝试匹配单词,而非枚举抽象的分割点
  • 实际性能更好:避免了大量无效的分割点检查

面试建议:

  1. 先口述思路:"这是字符串DP问题,用dp[i]表示前i个字符能否拆分"
  2. 提出优化:"与其枚举所有分割点,不如直接尝试字典中的单词,效率更高"
  3. 写🏆解法二的代码
  4. 强调关键点:"将wordDict转为set加速查找,dp[0]=True表示空串可拆分"
  5. 手动测试边界用例(空串、单个单词、重复使用)

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题30秒)好的,这是一道经典的字符串DP问题。要判断字符串s能否由字典中的单词拼接而成,单词可以重复使用。

我的思路是用动态规划:定义 dp[i] 表示前i个字符能否拆分。对于每个位置i,尝试字典中的每个单词,如果s以该单词结尾且前面部分可拆分,则dp[i]为真。

这样时间复杂度是O(n × m × L),其中n是字符串长度,m是字典单词数,L是单词平均长度。

面试官:很好,请写一下代码。

:(边写边说)

def word_break(s, wordDict):
    n = len(s)
    word_set = set(wordDict)  # 转为集合加速查找
    dp = [False] * (n + 1)
    dp[0] = True  # 空字符串可拆分

    for i in range(1, n + 1):
        for word in word_set:
            word_len = len(word)
            # 如果s以word结尾,且前面部分可拆分
            if i >= word_len and dp[i - word_len] and s[i - word_len:i] == word:
                dp[i] = True
                break  # 找到一个即可

    return dp[n]

核心是 dp[i] 表示前i个字符能否拆分,内层循环直接尝试匹配字典中的单词,避免枚举所有分割点。

面试官:测试一下?

:用示例 s="leetcode", wordDict=["leet","code"] 走一遍...

  • i=4时,尝试"leet",s[0:4]="leet"匹配!dp[0]=True,所以dp[4]=True
  • i=8时,尝试"code",s[4:8]="code"匹配!dp[4]=True,所以dp[8]=True
  • 返回dp[8]=True,结果正确!

再测边界情况 s="" 返回True(空串可拆分),s="catsandog" 无解返回False,结果正确!

高频追问

追问应答策略
"还有更优解吗?""时间已接近最优。可以用Trie树优化字符串匹配,但代码复杂度增加,实际提升不大。对于本题数据范围,当前解法足够高效"
"能输出所有可能的拆分方案吗?""可以!在dp基础上加回溯:从dp[n]往回找,记录每次使用的单词。时间复杂度可能达到O(2^n)因为方案数可能很多"
"如果字典非常大怎么办?""可以用Trie树存储字典,将查找从O(L)优化到O(L)(虽然渐进复杂度相同,但常数更小)。或者用字符串哈希避免切片操作"
"为什么不能用贪心?""反例:s='aaab',wordDict=['aa','aaa']。贪心选'aaa'会导致剩余'ab'无法拆分,但选'aa'+'aa'可以拆分成'aaab'...等等,这个反例本身也无解。真正的反例需要更复杂构造,但总之贪心无法保证全局最优"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:列表转集合加速查找
word_set = set(wordDict)  # O(1)查找 vs 列表的O(n)

# 技巧2:字符串切片 s[i:j]
s[0:4]  # 左闭右开,取索引0,1,2,3
s[i - word_len:i]  # 取以i结尾的word_len个字符

# 技巧3:any() 判断是否存在
any(condition for item in items)  # 只要有一个True就返回True

💡 底层原理(选读)

为什么是字符串DP而非完全背包?

  • 完全背包:物品无序,可以任意排列组合(如零钱兑换:5+1和1+5相同)
  • 字符串DP:有序匹配,必须按照字符串顺序拼接

本题虽然单词可以重复使用,但必须按顺序拼接成s,所以不是完全背包,而是字符串DP。

为什么解法二比解法一快?

  • 解法一:枚举所有分割点j(0到i-1),即使大部分分割点无效
  • 解法二:只尝试字典中的m个单词,当m << n时大幅减少计算
  • 极端情况:如果字典只有2个单词,解法二内层只循环2次,而解法一要循环n次

算法模式卡片 📐

  • 模式名称:字符串DP
  • 适用条件:判断字符串能否由某些子串组成/匹配
  • 识别关键词:"字符串拆分"、"子串匹配"、"能否组成"
  • 模板代码:
def string_dp(s, patterns):
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True

    for i in range(1, n + 1):
        for pattern in patterns:
            plen = len(pattern)
            if i >= plen and dp[i - plen] and s[i - plen:i] == pattern:
                dp[i] = True
                break

    return dp[n]

易错点 ⚠️

  1. 初始化错误:忘记设置 dp[0] = True,导致所有位置都无法转移

    • 错误:dp = [False] * (n + 1)
    • 正确:dp[0] = True; dp = [False] * (n + 1); dp[0] = True
  2. 字符串切片越界:未检查 i >= word_len 就切片

    • 错误:if s[i - word_len:i] == word
    • 正确:if i >= word_len and s[i - word_len:i] == word
  3. 忘记转集合:直接用列表查找 word in wordDict,时间复杂度O(m)

    • 错误:if word in wordDict
    • 正确:word_set = set(wordDict); if word in word_set

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:搜索引擎查询分词 — 用户输入"iphone手机壳",需要拆分成["iphone", "手机壳"]或["iphone", "手机", "壳"],判断是否为有效查询
  • 场景2:自然语言处理 — 中文分词系统判断一段文本能否由词典中的词语组成
  • 场景3:URL路由匹配 — 判断请求路径能否由预定义的路由规则拼接而成

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 140. 单词拆分IIHard字符串DP+回溯在本题基础上加回溯输出所有方案
LeetCode 472. 连接词Hard字符串DP判断单词能否由其他单词拼接而成
LeetCode 44. 通配符匹配Hard二维字符串DP支持*和?的字符串匹配
LeetCode 583. 两个字符串的删除操作Medium二维字符串DP最少删除次数使两个字符串相同

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:现在不仅要判断能否拆分,还要输出所有可能的拆分方案。例如s="catsanddog",wordDict=["cat","cats","and","sand","dog"],输出[["cats","and","dog"], ["cat","sand","dog"]]

💡 提示(实在想不出来再点开)

在DP的基础上加回溯:从dp[n]往回找,每次找到一个有效分割点就记录单词,递归处理前半部分。

✅ 参考答案
def word_break_all(s, wordDict):
    word_set = set(wordDict)
    n = len(s)

    # 先用DP判断每个位置能否拆分
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for word in word_set:
            wlen = len(word)
            if i >= wlen and dp[i - wlen] and s[i - wlen:i] == word:
                dp[i] = True
                break

    if not dp[n]:  # 无法拆分
        return []

    # 回溯找出所有方案
    result = []
    def backtrack(index, path):
        if index == 0:
            result.append(path[::-1])  # 倒序输出
            return
        for word in word_set:
            wlen = len(word)
            if index >= wlen and dp[index - wlen] and s[index - wlen:index] == word:
                path.append(word)
                backtrack(index - wlen, path)
                path.pop()

    backtrack(n, [])
    return result

核心思路:DP判断可行性,回溯枚举所有方案。时间复杂度O(2^n)因为方案数可能很多。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。