【中等】139. 单词拆分

4 阅读3分钟

给你一个字符串 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 中的所有字符串 互不相同

1. 生活案例:乐高积木挑战

想象你手里有一根长长的透明塑料管(字符串 s),你的任务是判断能不能用你积木盒里现有的积木块(wordDict)正好把这个塑料管填满。

  • 规则

    • 你只能使用积木盒里有的型号。
    • 同一种型号的积木你可以无限次使用。
    • 所有的积木必须严丝合缝地连接,不能重叠,也不能留空隙。
  • 例子:塑料管标着 applepenapple,你的积木盒里有 applepen

    • 你先塞入一个 apple,剩下的空间是 penapple
    • 再塞入一个 pen,剩下的空间是 apple
    • 最后再塞入一个 apple,刚好填满!返回 True

2. 代码解析与“生活化”注释

这段代码的核心逻辑是: “如果前面的部分已经能拼好,且剩下的一截正好是积木盒里的型号,那么到这里为止的全长就都能拼好。”

JavaScript

/**
 * @param {string} s - 目标长管子
 * @param {string[]} wordDict - 积木盒里的零件
 * @return {boolean} - 是否能正好拼满
 */
var wordBreak = function(s, wordDict) {
    // 为了找积木更快,把零件清单放进 Set(哈希表),这样一眼就能看出有没有这个型号
    let wordList = new Set(wordDict);
    let n = s.length;

    // dp[i] 代表:塑料管的前 i 个长度,是否能被积木完美拼满
    // dp 数组长度是 n+1,因为我们要考虑“长度为 0”的情况
    let dp = new Array(n + 1).fill(false);

    // 基础情况:长度为 0 时,默认是“拼好了”的(因为不需要任何积木)
    dp[0] = true;

    // i 代表当前我们正在尝试填满的管子总长度 (1 到 n)
    for (let i = 1; i <= n; i++) {
        // j 代表寻找一个“切割点”,把管子分成 [0, j][j, i] 两部分
        for (let j = 0; j < i; j++) {
            // 生活化解释:
            // 1. dp[j] 是 true 吗?(即:前 j 段管子已经拼好了吗?)
            // 2. 剩下的这一截 s.substring(j, i) 在积木盒里吗?
            if (dp[j] && wordList.has(s.substring(j, i))) {
                // 如果两条件都满足,说明到长度 i 为止,管子也能被完美填满
                dp[i] = true;
                // 既然长度 i 已经能拼好了,就不用再试其他的切割点 j 了,直接跳出内循环
                break;
            }
        }
    }

    // 最后看整根管子的长度 n 是否标记为 true
    return dp[n];
};

3. 为什么代码这样写?(算法思维)

  1. 分步检查:我们不是直接去看整根管子,而是从长度 1 开始看,长度 2、长度 3…… 每一个长度都基于之前的成功经验。

  2. 状态转移

    • 如果我们想知道长度为 10 的管子能不能拼好,我们会问:
    • “长度为 7 的能拼好吗?(dp[7])且最后那 3 个字母的积木我有吗?”
    • 或者“长度为 5 的能拼好吗?(dp[5])且最后那 5 个字母的积木我有吗?”
  3. 效率优化:代码里的 break 很关键。一旦发现长度 i 可以被某种方式拼好,就立刻处理下一个长度,不再浪费时间尝试其他拼法。

总结

这就是动态规划的魅力:不重复造轮子。为了知道现在能不能成功,先看看过去(更短的长度)有没有成功的记录,再接上现在的这一块积木。