力扣解题-139. 单词拆分

3 阅读8分钟

力扣解题-139. 单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true。

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

示例 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] 仅由小写英文字母组成

wordDict 中的所有字符串 互不相同

Related Topics

字典树、记忆化、数组、哈希表、字符串、动态规划


第一次解答

解题思路

核心方法:动态规划(DP)基础版,通过定义dp数组记录子串是否可拆分,利用哈希集合快速判断子串是否在字典中,时间复杂度O(n²)(n为字符串长度)、空间复杂度O(n + m)(m为字典单词数),是本题最直观、易实现的经典解法(注:原代码缺少return dp[sl];,属于笔误,不影响核心逻辑理解)。

核心逻辑拆解

单词拆分的核心是“逐步验证子串是否可拆分”:

  1. DP数组定义dp[j]表示字符串s的前j个字符(即s[0..j-1])能否被字典中的单词拼接而成;
  2. 初始条件dp[0] = true(空字符串可以被拆分,作为递归的基础条件);
  3. 状态转移逻辑
    • 对于每个位置j(代表前j个字符),遍历所有可能的分割点i0 ≤ i < j);
    • dp[i]为true(前i个字符可拆分),且子串s[i..j-1]存在于字典中,则dp[j]为true(前j个字符可拆分);
  4. 哈希优化:将字典存入HashSet,将子串存在性判断的时间复杂度从O(m)降至O(1)。
具体执行逻辑
  1. 字典预处理:将wordDict转换为HashSetset,方便快速查询;
  2. DP数组初始化
    • 长度为s.length()+1(覆盖0到s.length的所有子串长度);
    • 所有元素默认false,仅dp[0] = true(空字符串可拆分);
  3. 双层循环验证
    • 外层循环j:遍历所有子串长度(从1到s.length);
    • 内层循环i:遍历所有可能的分割点(从0到j-1);
    • dp[i]为true且s.substring(i,j)set中,标记dp[j] = true并跳出内层循环;
  4. 结果返回dp[s.length()]即为整个字符串是否可拆分的结果。
执行流程可视化(以示例1 s="leetcode"、wordDict=["leet","code"]为例)
j(子串长度)i(分割点)dp[i]子串s[i..j-1]是否在字典dp[j]说明
40true"leet"true前4个字符可拆分
84true"code"true前8个字符可拆分
结束----true返回true
关键细节说明
  • 子串范围s.substring(i,j)获取的是s中从索引i(包含)到j(不包含)的子串,对应前j个字符的后j-i个字符;
  • 提前终止内层循环:找到一个满足条件的i后立即break,避免无效遍历;
  • 边界条件dp[0] = true是核心,没有这个初始条件,所有后续验证都无法成立;
  • 原代码补充:需在方法末尾添加return dp[sl];才能正确返回结果(笔误不影响核心逻辑)。
性能说明
  • 时间复杂度:O(n²)(外层n次循环,内层平均n/2次循环,子串截取为O(k),k为子串长度,总体仍为O(n²));
  • 空间复杂度:O(n + m)(DP数组O(n) + HashSet存储字典O(m));
  • 优势:
    1. 逻辑直观,完全贴合动态规划“状态定义-初始条件-状态转移”的经典范式;
    2. 哈希集合优化查询效率,比直接遍历字典更高效;
    3. 代码简洁,易理解和实现。
public boolean wordBreak(String s, List<String> wordDict) {
    Set<String> set = new HashSet<>(wordDict);
    int sl=s.length();
    //初始化的时候,默认都是false
    boolean dp[]=new boolean[sl+1];
    //设置空字符串为true
    dp[0]=true;
    //遍历字符串,分割字符看是否有匹配的
    for(int j=1;j<=sl;j++){
        for(int i=0;i<j;i++){
            if(dp[i] && set.contains(s.substring(i,j))){
                dp[j]=true;
                break;
            }
        }
    }
    return dp[sl]; // 补充原代码缺失的返回语句
}

示例解答

解题思路

解法1:动态规划优化版(减少无效遍历,O(n×L)时间)

核心方法:在基础DP的基础上,限制内层循环的范围为“当前位置j往前最多字典最长单词长度”,避免无效的子串截取,将时间复杂度优化至O(n×L)(L为字典最长单词长度),适合长字符串场景。

代码实现
public boolean wordBreak(String s, List<String> wordDict) {
    Set<String> set = new HashSet<>(wordDict);
    int n = s.length();
    boolean[] dp = new boolean[n + 1];
    dp[0] = true;
    
    // 找到字典中最长单词的长度,限制内层循环范围
    int maxWordLen = 0;
    for (String word : wordDict) {
        maxWordLen = Math.max(maxWordLen, word.length());
    }
    
    for (int j = 1; j <= n; j++) {
        // 内层循环仅遍历j-maxWordLen到j-1,避免无效子串
        int start = Math.max(0, j - maxWordLen);
        for (int i = start; i < j; i++) {
            if (dp[i] && set.contains(s.substring(i, j))) {
                dp[j] = true;
                break;
            }
        }
    }
    return dp[n];
}
核心逻辑说明
  1. 最长单词长度优化:先遍历字典找到最长单词长度maxWordLen
  2. 内层循环范围限制:对于每个j,仅遍历i = max(0, j-maxWordLen)j-1,因为超过maxWordLen的子串不可能在字典中;
  3. 性能提升:当字典最长单词长度为20时,内层循环最多执行20次,而非j次,n=300时循环次数从约4.5万次降至6000次。
性能说明
  • 时间复杂度:O(n×L)(n为字符串长度,L为字典最长单词长度);
  • 空间复杂度:O(n + m)(与基础版一致);
  • 优势:
    1. 大幅减少内层循环次数,长字符串场景下效率显著提升;
    2. 逻辑与基础版一致,仅增加简单的范围限制,易理解。
解法2:记忆化递归(Top-Down,O(n×L)时间)

核心方法:通过递归+记忆化缓存,自顶向下验证子串是否可拆分,避免重复计算,逻辑更贴合“拆分字符串”的直观思路。

代码实现
public boolean wordBreak(String s, List<String> wordDict) {
    Set<String> set = new HashSet<>(wordDict);
    // 记忆化缓存:-1未计算,0不可拆分,1可拆分
    int[] memo = new int[s.length()];
    Arrays.fill(memo, -1);
    return dfs(s, 0, set, memo);
}

private boolean dfs(String s, int start, Set<String> set, int[] memo) {
    // 递归终止:已遍历完整个字符串,可拆分
    if (start == s.length()) {
        return true;
    }
    // 已计算过,直接返回结果
    if (memo[start] != -1) {
        return memo[start] == 1;
    }
    
    // 遍历所有可能的结束位置(最多到字典最长单词长度)
    int maxEnd = Math.min(start + 20, s.length()); // 题目限制单词长度≤20
    for (int end = start + 1; end <= maxEnd; end++) {
        String sub = s.substring(start, end);
        if (set.contains(sub) && dfs(s, end, set, memo)) {
            memo[start] = 1; // 标记可拆分
            return true;
        }
    }
    
    memo[start] = 0; // 标记不可拆分
    return false;
}
核心逻辑说明
  1. 记忆化缓存memo[start]记录从start索引开始的子串是否可拆分,避免重复递归;
  2. 递归终止条件start == s.length()时返回true(已拆分完所有字符);
  3. 递归验证:遍历从start开始的所有可能子串(长度1到20),若子串在字典中且剩余部分可拆分,则当前子串可拆分;
  4. 缓存结果:将递归结果存入memo,后续直接使用。
性能说明
  • 时间复杂度:O(n×L)(每个起始位置最多遍历L次,L≤20);
  • 空间复杂度:O(n)(递归栈深度+memo数组);
  • 优势:
    1. 自顶向下的递归逻辑更贴合“拆分字符串”的直观思路;
    2. 记忆化避免重复计算,效率与优化版DP持平;
  • 劣势:递归栈有额外开销,n=300时栈深度最大为300(无溢出风险)。

总结

  1. 基础DP法(第一次解答):O(n²)时间+O(n+m)空间,逻辑直观,易实现,是本题的核心解法;
  2. DP优化版:O(n×L)时间+O(n+m)空间,限制内层循环范围,长字符串场景更高效;
  3. 记忆化递归:O(n×L)时间+O(n)空间,自顶向下解题,逻辑更贴合直观思路;
  4. 关键技巧
    • 核心思想:单词拆分的核心是“子问题可拆分+当前子串在字典中”,DP数组记录子问题结果;
    • 效率优化:限制内层循环范围至字典最长单词长度,可大幅减少无效计算;
    • 结构选择:工程中优先选DP优化版(无递归开销),学习阶段可通过记忆化递归理解核心逻辑。