【LeetCode 热题 100】139. 单词拆分——(解法一)记忆化搜索

Problem: 139. 单词拆分

文章目录

整体思路

这段代码旨在解决经典的 "单词拆分" (Word Break) 问题。问题要求判断一个给定的非空字符串 s 是否可以被分割成一个或多个在字典 wordDict 中出现的单词。

该算法采用的是一种 自顶向下(Top-Down)的动态规划 方法,即 记忆化搜索 (Memoization) 。它将"字符串s能否被拆分"的大问题,分解为"s的某个前缀能否被拆分"的子问题,并通过递归解决,同时缓存子问题的解以避免重复计算。

算法的核心逻辑步骤如下:

  1. 预处理

    • 建立快速查询字典 :将输入的 List<String> wordDict 转换为一个 HashSet<String> words。这是一个关键的性能优化,因为它将查询一个单词是否在字典中的时间复杂度从 O(N*L)(遍历列表)降低到了平均 O(L)(哈希查找,L为单词长度)。
    • 计算最大词长 maxLen :遍历字典,找出其中最长单词的长度。这个 maxLen 将用于后续的剪枝优化。
  2. 状态定义与递归关系

    • 算法的核心是递归函数 dfs(i),其状态定义为:
      dfs(i) = 字符串 s 的前 i 个字符(即 s.substring(0, i))是否可以被成功拆分
    • 为了判断 s 的前 i 个字符能否被拆分,算法会尝试所有的最后一个单词 的可能性。它会从后往前扫描,寻找一个切分点 j (j < i),使得:
      a. 后半部分 s.substring(j, i) 是一个在字典中的单词。
      b. 前半部分 s.substring(0, j) 可以被成功拆分(这通过递归调用 dfs(j) 来判断)。
    • 只要能找到任何一个 满足上述两个条件的切分点 j,就说明 s 的前 i 个字符是可以被拆分的。
  3. 记忆化与剪枝

    • 记忆化 (Memoization) :使用一个 memo 数组来存储每个子问题 dfs(i) 的计算结果。memo[i] 的值有三种状态:-1(未计算),0(不可拆分),1(可拆分)。在 dfs(i) 的开头,先检查 memo[i],如果不是-1,就直接返回已存的结果,避免重复的递归树展开。
    • 剪枝 (Pruning) :在 dfs(i) 内部的循环中,利用预计算的 maxLen 来缩小搜索范围。切分点 j 无需从 i-1 一直回溯到 0,只需回溯到 i - maxLen 即可。因为如果 i-j > maxLen,那么 s.substring(j, i) 的长度必然大于字典中最长的单词,它不可能是字典的一部分。这个剪枝显著减少了不必要的 substringHashSet.contains 操作。
  4. 基础情况 (Base Case)

    • dfs(0) 代表对空字符串的判断。一个空字符串可以被视作成功拆分(因为它不需要任何单词),所以 dfs(0) 返回 1(代表true)。这是递归的终点。

完整代码

java 复制代码
import java.util.*;

class Solution {
    /**
     * 判断字符串 s 是否可以被字典中的单词拆分。
     * @param s 目标字符串
     * @param wordDict 字典单词列表
     * @return 如果可以拆分则返回 true,否则返回 false
     */
    public boolean wordBreak(String s, List<String> wordDict) {
        // 1. 预处理:计算字典中单词的最大长度,用于后续剪枝。
        int maxLen = 0;
        for (String word : wordDict) {
            maxLen = Math.max(maxLen, word.length());
        }
        
        // 2. 预处理:将 List 转换为 HashSet,以实现 O(1) 平均时间复杂度的单词查询。
        Set<String> words = new HashSet<>(wordDict);
        
        int n = s.length();
        // memo: 记忆化数组。memo[i] 存储 dfs(i) 的结果。
        // -1: 未计算, 0: false (不可拆分), 1: true (可拆分)。
        int[] memo = new int[n + 1];
        Arrays.fill(memo, -1);
        
        // 启动对整个字符串 s (长度为 n) 的递归求解。
        return dfs(n, maxLen, s, words, memo) == 1;
    }

    /**
     * 记忆化搜索函数。
     * @param i 当前要判断的前缀的长度,即 s.substring(0, i)
     * @param maxLen 字典中单词的最大长度(用于剪枝)
     * @param s 原始字符串
     * @param words 字典的 HashSet
     * @param memo 记忆化数组
     * @return 1 表示可以拆分,0 表示不可以
     */
    private int dfs(int i, int maxLen, String s, Set<String> words, int[] memo) {
        // 基础情况:长度为 0 的前缀(空字符串)总是可以被"拆分"的。
        if (i == 0) {
            return 1;
        }

        // 记忆化检查:如果该子问题已经计算过,直接返回结果。
        if (memo[i] != -1) {
            return memo[i];
        }

        // 核心循环:尝试所有可能的最后一个单词。
        // j 是切分点,s.substring(j, i) 是尝试的最后一个单词。
        // 剪枝优化:j 的下界被 maxLen 限制,避免了不必要的检查。
        for (int j = i - 1; j >= Math.max(i - maxLen, 0); j--) {
            // 检查两个条件:
            // 1. s.substring(j, i) 是否在字典中。
            // 2. 剩余的前缀 s.substring(0, j) 是否也可以被拆分(递归调用)。
            if (words.contains(s.substring(j, i)) && dfs(j, maxLen, s, words, memo) == 1) {
                // 如果找到一种可行的拆分方式,记录结果 1 并立即返回。
                return memo[i] = 1;
            }
        }
        
        // 如果循环结束后仍未找到任何可行的拆分方式,记录结果 0 并返回。
        return memo[i] = 0;
    }
}

时空复杂度

时间复杂度:O(N * L^2)

  • N 是字符串 s 的长度。
  • L 是字典 wordDict 中单词的最大长度 (maxLen)。
  1. 状态数量 :由于记忆化的存在,每个子问题 dfs(i)i0N)只会被实际计算一次。总共有 O(N) 个不同的状态。
  2. 每个状态的计算时间 :在 dfs(i) 函数内部,主要的开销来自 for 循环。
    • 循环最多执行 L 次(因为 j 的范围被 maxLen 限制)。
    • 在循环内部,s.substring(j, i) 操作需要 O(L) 的时间,因为它需要复制一个长度最多为 L 的子串。
    • words.contains(...)HashSet 上的操作,其时间复杂度与被检查字符串的长度成正比(用于计算哈希值),因此也是 O(L)。
    • 因此,循环内部单次迭代的复杂度是 O(L)。
    • 总的来说,dfs(i) 的计算时间是 L * O(L) = O(L^2)
  3. 综合分析
    总时间复杂度 = (状态数量) × (每个状态的计算时间) = O(N) * O(L^2)
    预处理部分(构建 HashSet)的时间复杂度为 O(M*k)M为字典词数,k为平均词长),通常被 O(N*L^2) 主导。

空间复杂度:O(N + D)

  • N 是字符串 s 的长度。
  • D 是字典中所有字符的总数。
  1. 记忆化数组 memo :创建了一个大小为 N+1 的数组,占用 O(N) 空间。
  2. 递归调用栈 :递归的最大深度可以达到 N(例如,当 s = "aaaa..."dict = ["a"] 时)。因此,递归栈占用的空间是 O(N)
  3. 字典 wordsHashSet 需要存储字典中的所有单词。如果字典中有 M 个单词,平均长度为 k,则其空间为 O(M*k),我们记为 D

综合分析

算法所需的总空间是 O(N) (memo) + O(N) (stack) + O(D) (set)。因此,最终的空间复杂度为 O(N + D)

参考灵神

相关推荐
cyforkk28 分钟前
ArrayList vs LinkedList:底层原理与实战选择指南
java
孟婆来包棒棒糖~2 小时前
泛型与反射
java·反射·javase·泛型
A尘埃2 小时前
Spring Event 企业级应用
java·spring·event
超级皮皮3 小时前
力扣热题之stack
算法·leetcode·职场和发展
weixin_470740363 小时前
某算法的python执行汇编
汇编·python·算法
是乐谷4 小时前
燧原科技招大模型训练算法工程师
科技·算法
圣保罗的大教堂5 小时前
leetcode 1277. 统计全为 1 的正方形子矩阵 中等
leetcode
Best_Liu~5 小时前
策略模式 vs 适配器模式
java·spring boot·适配器模式·策略模式
direction__5 小时前
Java Main无法初始化主类的原因与解决方法(VsCode工具)
java·vscode