139. 单词拆分 (Medium)

专题归类: 10-动态规划 LeetCode 链接: https://leetcode.cn/problems/word-break/


在线做题: 写 Python 代码并运行 · Java 跳转 LeetCode

题目描述

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

提示:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s 和 wordDict[i] 仅由小写英文字母组成

题目详细分析

  • 数据范围:s 最长 300,字典最多 1000 个单词,单词最长 20。O(n²) 的 DP 可接受。
  • 核心约束:单词可以重复使用,顺序要求完全匹配(完全背包的字符串版本)。
  • 边界条件:空字符串可以拆分(dp[0] = True);字典中单词都是小写字母。
  • 隐藏条件:拆分的”切割点”是 dp 的关键——如果 s[j:i] 是字典词且 s[0:j] 可拆分,则 s[0:i] 可拆分。

小白版直白理解

你有一堆积木(字典里的单词),想拼成一个指定的长条(字符串 s)。积木可以重复使用,关键是拼接的顺序必须和长条完全一致。你从左边开始拼——先看前 1 个字母能不能用一块积木拼成,前 2 个字母能不能,前 3 个能不能……每多拼一块,就看新多出来的那截字母是否正好是一块积木。这就好比玩拼图,每次在末尾拼上一块。


解题思路

思路一:动态规划(推荐)

核心洞察:定义 dp[i] 表示 s 的前 i 个字符(s[0:i])能否被拆分。对于每个位置 i,检查是否存在一个切割点 j < i,使得 s[0:j] 可拆分且 s[j:i] 在字典中。这相当于完全背包的”组合”问题——物品能否按顺序拼成目标。

DP 五步法:

  1. dp 定义dp[i] 表示 s 的前 i 个字符是否能被拆分成字典中的单词
  2. 递推公式dp[i] = any(dp[j] and s[j:i] in word_set for j in range(i))
  3. 初始化dp[0] = True(空字符串视为可拆分)
  4. 遍历顺序:从 i=1 到 len(s),内层遍历 j < i
  5. 举例验证:s=“leetcode”, wordDict=[“leet”,“code”] → dp[0]=T, dp[4]=T(“leet”), dp[8]=dp[4] and “code” in dict = T ✓
def wordBreak(s, wordDict):
    word_set = set(wordDict)               # 哈希集,O(1) 查找
    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]

思路二:BFS / 单词接龙

将字符串索引视为节点,从 0 出发,每次”接上”一个字典中的单词跳到一个新位置,看能否到达 n。

from collections import deque
 
def wordBreak(s, wordDict):
    word_set = set(wordDict)
    n = len(s)
    queue = deque([0])
    visited = [False] * (n + 1)
    visited[0] = True
 
    while queue:
        start = queue.popleft()
        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 False

思路三:Trie 树优化(字典很大时)

如果字典特别大但单词较短,用 Trie 树优化子串查找。

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False
 
def wordBreak(s, wordDict):
    # 构建 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_word = True
 
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(n):
        if dp[i]:
            node = root
            for j in range(i, n):
                if s[j] not in node.children:
                    break
                node = node.children[s[j]]
                if node.is_word:
                    dp[j + 1] = True
    return dp[n]

易错点

  • dp 索引偏移dp[i] 对应 s[0:i](前 i 个字符,不包含 s[i]),所以 dp[n] 是最终答案。
  • skipping break:找到解后及时 break 内层循环,避免多余计算。
  • 子串截取性能s[j:i] 是 O(k) 操作。字典用 set 保证 O(1) 查找。如果 n 很大可考虑用 Trie 树优化。
  • 字典去重:dict 中可能有重复单词,用 set 去重可以优化,但不是必须。

框架提炼

完全背包(字符串拼接 / 顺序敏感)模板:

def string_break(s, word_set):
    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]

与硬币找零的区别:这里物品有”顺序”要求(必须按字符顺序拼接),所以两层循环不能交换。


关联题目