LeetCode算法日记 - Day 87: 单词拆分
目录
1. 单词拆分
1.1 题目解析
1.2 解法
1.3 代码实现
1. 单词拆分
https://leetcode.cn/problems/word-break/description/
给你一个字符串 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 <= 3001 <= wordDict.length <= 10001 <= wordDict[i].length <= 20s和wordDict[i]仅由小写英文字母组成wordDict中的所有字符串 互不相同
1.1 题目解析
题目本质
判断字符串能否由字典中的单词拼接而成。本质是"字符串分割验证"问题,核心在于找到合法的分割点,使得每一段都是字典单词。
常规解法
最直观的想法是递归回溯,尝试所有可能的分割方案:从左到右枚举每个位置,如果前缀在字典中,就递归检查剩余部分。例如 "leetcode",先试 "l"、"le"、"lee"、"leet",如果 "leet" 在字典,再递归检查 "code"。
问题分析
递归回溯会产生大量重复计算。例如 s = "aaaaaab",字典 = ["a", "aa"],检查后半部分时会重复判断同样的子串很多次。时间复杂度最坏达到 O(2^n),对于长度 300 的字符串会超时。
思路转折
要想高效 → 必须避免重复计算 → 动态规划。
关键观察:如果我们已经知道前 j 个字符能拆分,那么只需判断 [j, i] 这段是否在字典中,就能知道前 i 个字符能否拆分。这样,每个位置只计算一次,通过"记录历史结果"来加速判断。
1.2 解法
算法思想: 动态规划。定义 dp[i] 表示字符串前 i 个字符能否被拆分成字典单词。对于每个位置 i,枚举所有可能的分割点 j(0 ≤ j < i),如果前 j 个字符能拆分(dp[j] = true)且 [j, i) 这段在字典中,则 dp[i] = true。
状态转移方程:
dp[i] = true 当存在 j (0 ≤ j < i) 满足:dp[j] = true && s[j...i-1] ∈ wordDict
i)优化查找: 将字典转为 HashSet,使查找时间从 O(n) 降到 O(1)
ii)保存原长度: 用变量 n 保存字符串原始长度,因为后续要修改字符串
iii)初始化 dp: 创建长度为 n+1 的布尔数组,dp[0] = true 表示空字符串可以拆分
iv)处理下标: 在字符串前加空格 s = " " + s,使 dp 数组下标与字符串下标对齐,方便截取
v)双层循环枚举:
-
外层 i 从 1 到 n:表示检查前 i 个字符
-
内层 j 从 i 到 1 倒序:表示尝试在 j 处分割
vi)判断并更新: 如果 dp[j-1] 为 true 且 s.substring(j, i+1) 在字典中,则 dp[i] = true,并 break 优化
易错点
-
substring 左闭右开: s.substring(j, i+1) 实际截取 [j, i] 范围的字符,因为 Java substring 的 end 参数不包含在结果中
-
先保存原长度: 必须在 s = " " + s 之前执行 int n = s.length(),否则 n 会多 1,导致数组越界
-
i 和 j 的含义: i 是检查的终点位置(前 i 个字符),j 是尝试的分割点,将字符串分成 [0, j-1] 和 [j, i] 两部分
-
dp 长度问题: dp 数组长度是 n+1(基于原始字符串长度),因为 dp[0] 表示空串,dp[n] 表示整个字符串
-
break 的作用: 找到一种拆分方法后立即跳出内层循环,避免重复设置 dp[i],提升性能
1.3 代码实现
class Solution {public boolean wordBreak(String s, List<String> wordDict) {// 优化:HashSet 查找 O(1)Set<String> hash = new HashSet<>(wordDict);// 先保存原长度(关键)int n = s.length();// dp[i]: 前 i 个字符能否拆分boolean[] dp = new boolean[n + 1];dp[0] = true; // 空串可以拆分// 加空格对齐下标s = " " + s;for (int i = 1; i <= n; i++) {for (int j = i; j >= 1; j--) {// 前半部分能拆分 && 后半部分在字典if (dp[j-1] && hash.contains(s.substring(j, i+1))) {dp[i] = true;break; // 找到即停止}}}return dp[n];}
}
复杂度分析
-
时间复杂度: O(n² × L),其中 n 是字符串长度,L 是单词平均长度。两层循环 O(n²),substring 和字符串比较需要 O(L)
-
空间复杂度: O(n + m),其中 n 是 dp 数组大小,m 是字典中所有单词的总长度(HashSet 存储)
