【LeetCode 热题 100】139. 单词拆分——(解法一)记忆化搜索
Problem: 139. 单词拆分
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:O(N * L^2)
- 空间复杂度:O(N + D)
整体思路
这段代码旨在解决经典的 “单词拆分” (Word Break) 问题。问题要求判断一个给定的非空字符串 s
是否可以被分割成一个或多个在字典 wordDict
中出现的单词。
该算法采用的是一种 自顶向下(Top-Down)的动态规划 方法,即 记忆化搜索 (Memoization)。它将“字符串s
能否被拆分”的大问题,分解为“s
的某个前缀能否被拆分”的子问题,并通过递归解决,同时缓存子问题的解以避免重复计算。
算法的核心逻辑步骤如下:
-
预处理:
- 建立快速查询字典:将输入的
List<String> wordDict
转换为一个HashSet<String> words
。这是一个关键的性能优化,因为它将查询一个单词是否在字典中的时间复杂度从 O(N*L)(遍历列表)降低到了平均 O(L)(哈希查找,L为单词长度)。 - 计算最大词长
maxLen
:遍历字典,找出其中最长单词的长度。这个maxLen
将用于后续的剪枝优化。
- 建立快速查询字典:将输入的
-
状态定义与递归关系:
- 算法的核心是递归函数
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
个字符是可以被拆分的。
- 算法的核心是递归函数
-
记忆化与剪枝:
- 记忆化 (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)
的长度必然大于字典中最长的单词,它不可能是字典的一部分。这个剪枝显著减少了不必要的substring
和HashSet.contains
操作。
- 记忆化 (Memoization):使用一个
-
基础情况 (Base Case):
dfs(0)
代表对空字符串的判断。一个空字符串可以被视作成功拆分(因为它不需要任何单词),所以dfs(0)
返回 1(代表true
)。这是递归的终点。
完整代码
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
)。
- 状态数量:由于记忆化的存在,每个子问题
dfs(i)
(i
从0
到N
)只会被实际计算一次。总共有O(N)
个不同的状态。 - 每个状态的计算时间:在
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)
。
- 循环最多执行
- 综合分析:
总时间复杂度 = (状态数量) × (每个状态的计算时间) =O(N) * O(L^2)
。
预处理部分(构建HashSet
)的时间复杂度为O(M*k)
(M
为字典词数,k
为平均词长),通常被O(N*L^2)
主导。
空间复杂度:O(N + D)
N
是字符串s
的长度。D
是字典中所有字符的总数。
- 记忆化数组
memo
:创建了一个大小为N+1
的数组,占用 O(N) 空间。 - 递归调用栈:递归的最大深度可以达到
N
(例如,当s = "aaaa..."
且dict = ["a"]
时)。因此,递归栈占用的空间是 O(N)。 - 字典
words
:HashSet
需要存储字典中的所有单词。如果字典中有M
个单词,平均长度为k
,则其空间为O(M*k)
,我们记为D
。
综合分析:
算法所需的总空间是 O(N) (memo) + O(N) (stack) + O(D) (set)。因此,最终的空间复杂度为 O(N + D)。
参考灵神