LeetCode 139 单词拆分:python3 题解


题目链接:139. 单词拆分


1. 题目理解

题目大意
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判断 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

关键点

  1. 拆分:字符串 s 必须被完全覆盖,不能剩字符。
  2. 复用:字典里的单词可以重复使用。
  3. 子集:不需要使用字典里的所有单词,只要能拼出 s 即可。

示例分析

  • s = "leetcode", wordDict = ["leet", "code"]
    • "leetcode" 可以拆分为 "leet" + "code"。这两个词都在字典里。 -> 返回 True
  • s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
    • 虽然 "cats", "and", "dog" 都在字典里,但拼起来是 "catsanddog",多了一个 'd'。
    • 或者 "cat", "sand", "og",但 "og" 不在字典里。
    • 无法完全匹配。 -> 返回 False

2. 核心解题思路

这道题是经典的 动态规划 (Dynamic Programming) 问题,也可以看作是一个 图论搜索 问题。

思路一:动态规划 (自底向上)

我们可以把大问题拆解成小问题。
假设我们想知道字符串 s 的前 i 个字符(即 s[0:i])是否能被拆分。

  • 如果 s[0:i] 能被拆分,那么一定存在一个分割点 j (0 <= j < i),使得:
    1. s[0:j] 能被拆分(这是子问题)。
    2. s[j:i] 这个子串本身就在字典里。

定义状态
dp[i] 表示字符串 s 的前 i 个字符(即 s[:i])是否可以被成功拆分。

  • dp[i] = True:可以拆分。
  • dp[i] = False:不可以拆分。

初始状态
dp[0] = True
为什么?因为空字符串可以被视为“已经成功拆分”(或者理解为匹配的起点)。

状态转移方程
对于每一个位置 i (从 1 到 len(s)),我们遍历所有可能的前一个分割点 j (从 0 到 i-1)。
如果 dp[j]True 并且 s[j:i] 在字典中,那么 dp[i] 就可以设为 True

思路二:记忆化搜索 (自顶向下)

这是动态规划的递归版本。
定义一个函数 can_break(start_index),表示从 start_index 开始到字符串末尾的子串是否能被拆分。

  • 如果 start_index 到达了字符串末尾,说明前面都匹配成功了,返回 True
  • 否则,尝试从 start_index 开始截取不同长度的子串,如果子串在字典里,就递归检查剩下的部分。
  • 为了避免重复计算,使用一个缓存(Memoization)记录已经计算过的 start_index 的结果。

思路三:广度优先搜索 (BFS)

把字符串的每个索引位置看作图的一个节点。

  • 起点是索引 0
  • 如果 s[i:j] 在字典里,说明从节点 i 可以跳到节点 j
  • 我们的目标是看能否从节点 0 跳到节点 len(s)
  • 使用队列进行 BFS,并用一个 visited 集合记录访问过的索引,防止死循环和重复计算。

3. 代码实现与详解

为了代码的完整性,我提供了三种解法。推荐优先掌握“动态规划”解法,它是面试中最标准、最通用的答案。

解法 1:动态规划 (推荐)【⭐】

from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        # 1. 预处理:将列表转换为集合,查找速度从 O(N) 提升到 O(1)
        word_set = set(wordDict)
        n = len(s)
        
        # 2. 创建 dp 数组
        # dp[i] 表示 s 的前 i 个字符 (s[:i]) 是否能被拆分
        # 数组长度为 n + 1,因为要包含空字符串的情况 (dp[0])
        dp = [False] * (n + 1)
        
        # 3. 初始化
        # 空字符串视为匹配成功,这是递推的基石
        dp[0] = True
        
        # 4. 遍历字符串的每一个位置 i (代表前 i 个字符)
        for i in range(1, n + 1):
            # 5. 遍历所有可能的分割点 j
            # 我们只需要检查 s[j:i] 是否在字典中,且 s[:j] 是否可拆分
            # 优化技巧:如果字典中单词最大长度为 max_len,j 不需要从 0 开始,
            # 可以从 max(0, i - max_len) 开始,减少无效检查。
            # 这里为了逻辑清晰,展示基础版本,实际面试可加上长度优化。
            for j in range(i):
                # 核心判断:
                # 1. dp[j] 为 True:说明前 j 个字符已经匹配成功
                # 2. s[j:i] in word_set:说明剩下的这部分也是个单词
                if dp[j] and s[j:i] in word_set:
                    dp[i] = True
                    # 一旦找到一种可行的拆分方法,就可以停止检查 i 位置了
                    break
        
        # 6. 返回结果
        # dp[n] 代表整个字符串 s 是否能被拆分
        return dp[n]

复杂度分析

  • 时间复杂度\(O(n^3)\)。外层循环 \(n\),内层循环 \(n\),字符串切片和哈希查找平均 \(O(n)\)。但在实际数据中,由于 break 的存在和单词长度限制,通常远快于最坏情况。
  • 空间复杂度\(O(n)\)。用于 dp 数组和 word_set

解法 2:记忆化递归 (Top-Down DP)

这种写法逻辑上更符合人类直觉:“从头开始试,能匹配就递归看后面”。

from typing import List
from functools import lru_cache

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        word_set = set(wordDict)
        n = len(s)
        
        # 使用 lru_cache 装饰器自动实现记忆化
        # 也可以手动维护一个 memo 字典
        @lru_cache(None)
        def dfs(start_index: int) -> bool:
            # 基准情况:如果指针已经到了字符串末尾,说明成功匹配完所有字符
            if start_index == n:
                return True
            
            # 尝试从 start_index 开始,截取不同长度的子串
            for end_index in range(start_index + 1, n + 1):
                # 如果截取的子串在字典里
                if s[start_index:end_index] in word_set:
                    # 递归检查剩下的部分是否能匹配
                    if dfs(end_index):
                        return True
            
            # 如果所有尝试都失败了
            return False

        return dfs(0)

复杂度分析

  • 时间复杂度\(O(n^3)\)。状态数 \(n\),每个状态转移需要 \(O(n^2)\) (遍历子串)。
  • 空间复杂度\(O(n)\)。递归栈深度和缓存空间。

解法 3:广度优先搜索 (BFS)

将问题转化为“从索引 0 能否到达索引 n"。

from typing import List
from collections import deque

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        word_set = set(wordDict)
        n = len(s)
        
        # 队列存储当前可以到达的索引位置
        queue = deque([0])
        # 记录已经访问过的索引,避免重复入队 (剪枝)
        visited = [False] * (n + 1)
        visited[0] = True
        
        while queue:
            # 取出当前所在的起始位置
            start = queue.popleft()
            
            # 尝试从 start 往后延伸,看能匹配到哪些单词
            for end in range(start + 1, n + 1):
                # 如果 end 已经访问过,说明从这个点出发的路径已经探索过了,跳过
                if visited[end]:
                    continue
                
                # 如果子串在字典里
                if s[start:end] in word_set:
                    # 如果刚好到达末尾,直接返回 True
                    if end == n:
                        return True
                    
                    # 否则,将新的位置加入队列,并标记为已访问
                    queue.append(end)
                    visited[end] = True
                    
        return False

复杂度分析

  • 时间复杂度\(O(n^3)\)。最坏情况下每个节点都要尝试所有后续边。
  • 空间复杂度\(O(n)\)。队列和 visited 数组。

4. 优化技巧 (针对动态规划)

在解法 1 中,内层循环 for j in range(i) 可能会检查很多无效的长度。例如,如果字典里最长的单词只有 5 个字符,那么 s[j:i] 的长度如果超过 5,肯定不在字典里。

优化后的 DP 代码片段

        # 计算字典中最长单词的长度
        max_len = max(len(w) for w in wordDict) if wordDict else 0
        
        for i in range(1, n + 1):
            # j 只需要从 i - max_len 开始检查即可,不需要从 0 开始
            # 同时 j 不能小于 0
            start_j = max(0, i - max_len)
            for j in range(start_j, i):
                if dp[j] and s[j:i] in word_set:
                    dp[i] = True
                    break

加上这个优化后,时间复杂度可以降低到接近 \(O(n \cdot k)\),其中 \(k\) 是字典中单词的最大长度。这在 s 很长但单词很短时非常有效。


5. 总结与建议

  1. 首选解法动态规划 (解法 1)。它是迭代式的,没有递归深度的限制,逻辑清晰,是解决此类“拆分”、“拼接”问题的标准模板。
  2. 关键细节
    • 一定要把 wordDict 转成 set,否则查找单词是 \(O(N)\) 会变成 \(O(N^2)\),导致超时。
    • dp[0] = True 是初始化关键,不要漏掉。
    • 理解 dp[i] 代表的是 s[:i] (前 i 个字符),而不是下标为 i 的字符。
  3. 何时用 BFS/DFS:如果题目要求输出所有可能的拆分方案(如 LeetCode 140 题),则必须用 DFS/回溯。如果只问“是否可行”(True/False),DP 通常效率更稳。


posted @ 2026-03-03 15:17  MoonOut  阅读(92)  评论(0)    收藏  举报