拼图大师:从“暴力尝试“到“动态规划“的拆字艺术

拼图大师:从"暴力尝试"到"动态规划"的拆字艺术

LeetCode 139. 单词拆分 ------ 当你面对一串字符,怎么判断它是不是一堆单词的"拼图"?


一、故事开场:你是拼图大师

想象你面前有一盒拼图碎片,上面写着各种单词:"leet""code""apple""pen"...

现在有人给你一幅完整的画:"leetcode"

你的任务是:**能不能用盒子里的碎片,把这幅画完整地拼出来?**碎片可以重复使用,但必须是连续的、不能重叠。

比如 "leetcode" 可以拼成 "leet" + "code",所以答案是 true

"catsandog"["cats", "dog", "sand", "and", "cat"] 就拼不出来 ------ 无论你从哪开始拆,总会剩下几个孤零零的字母。

这就是 LeetCode 第 139 题 ------ 单词拆分。它看起来像个字符串匹配题,实际上藏着递归与动态规划的精髓。


二、暴力解法:为什么不直接枚举所有拼法?

最笨的方法:枚举字符串所有可能的切分方式,检查每一部分是不是都在字典里。

比如 "leetcode",切分方式有:

  • l | e | e | t | c | o | d | e
  • le | etc | ode
  • leet | code
  • ...

切分方式是指数级增长的。对于一个长度为 nnn 的字符串,切分点的组合数是 O(2n)O(2^n)O(2n)。

面试官听了会打哈欠。我们需要更聪明的方法。


三、解法一:记忆化搜索(拼图大师的直觉)

核心思路

作为一个拼图大师,你的直觉是什么?

从画的左边开始,拿一块碎片比划一下。对上了?好,把这块拿掉,看看剩下的部分能不能继续拼。

这就是深度优先搜索(DFS)

复制代码
"leetcode"
  ↑
尝试 "l" → 不在字典 ❌
尝试 "le" → 不在字典 ❌
尝试 "lee" → 不在字典 ❌
尝试 "leet" → 在字典 ✅
            ↓
      递归检查 "code"
              ↑
        尝试 "c" → 不在字典 ❌
        尝试 "co" → 不在字典 ❌
        尝试 "cod" → 不在字典 ❌
        尝试 "code" → 在字典 ✅
                  ↓
            递归检查 ""
                    ↑
              空了,成功!✅

问题:重复劳动

但如果画很长,你可能会反复面对同一个"剩余部分"。比如:

复制代码
"applepenapple"
      ↑
"apple" 后面剩 "penapple"
              ↑
        "pen" 后面剩 "apple"

如果另一个分支也到了 "apple" 这个剩余部分,你难道要重新算一遍吗?

解决:记忆化(Memoization)

用一个小本本(memo 数组)记下:从位置 i 开始,能不能拼成功?

java 复制代码
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> wordSet = new HashSet<>(wordDict);
        Boolean[] memo = new Boolean[s.length()];  // null = 未计算
        return dfs(s, wordSet, 0, memo);
    }

    private boolean dfs(String s, Set<String> wordSet, int index, Boolean[] memo) {
        if (index == s.length()) return true;      // 拼完了!
        if (memo[index] != null) return memo[index]; // 查小本本

        for (int i = index + 1; i <= s.length(); i++) {
            String word = s.substring(index, i);
            if (wordSet.contains(word) && dfs(s, wordSet, i, memo)) {
                memo[index] = true;
                return true;
            }
        }

        memo[index] = false;
        return false;
    }
}

关键点: Boolean[] 而不是 boolean[],因为默认值 null 代表"还没算过"。

复杂度

  • 时间 :O(n×m×l)O(n \times m \times l)O(n×m×l) ------ nnn 为字符串长度,mmm 为字典大小,lll 为单词平均长度
  • 空间 :O(n)O(n)O(n) ------ 递归栈 + memo 数组

评价

就像一个老练的拼图大师,他一边试一边记:"从第 5 块开始我试过了,不行。"下次再遇到,直接翻小本本,不再浪费时间。


四、解法二:动态规划(拼图大师的"上帝视角")

核心思路

DFS 是"从左到右试",动态规划则是"把每个位置能不能拼出来,先算好存起来"。

定义 dp[i]字符串的前 i 个字符 (也就是 s[0..i-1])能不能被拆分。

复制代码
"leetcode"
 0  1  2  3  4  5  6  7  8
 l  e  e  t  c  o  d  e
 ↑              ↑
dp[0]          dp[4]
  • dp[0] = true ------ 空字符串,默认可以
  • dp[4] = true ------ "leet" 在字典里
  • dp[8] = true ------ "code" 在字典里,且 dp[4] = true

状态转移

对于每个位置 i,检查字典里的每个单词 word

复制代码
如果:dp[i - word.length()] == true   (前面能拼出来)
    且:s[i-word.length() .. i-1] == word  (末尾正好是这个单词)
那么:dp[i] = true

代码实现

java 复制代码
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        boolean[] dp = new boolean[n + 1];
        dp[0] = true;  // 空串可以被拆分

        for (int i = 1; i <= n; i++) {
            for (String word : wordDict) {
                int len = word.length();
                if (i >= len && dp[i - len]
                        && s.substring(i - len, i).equals(word)) {
                    dp[i] = true;
                    break;  // 已经找到一种拆分方式,够了
                }
            }
        }

        return dp[n];
    }
}

填表演示(以 "leetcode" 为例)

i 子串 检查单词 dpi-len 匹配? dpi
0 "" --- --- --- true(初始化)
1 "l" "leet" dp-3 --- false
2 "le" "leet" dp-2 --- false
3 "lee" "leet" dp-1 --- false
4 "leet" "leet" dp0=true true
5 "leetc" "code" dp1=false --- false
6 "leetco" "code" dp2=false --- false
7 "leetcod" "code" dp3=false --- false
8 "leetcode" "code" dp4=true true

复杂度

  • 时间 :O(n×m×l)O(n \times m \times l)O(n×m×l) ------ 与 DFS 同级
  • 空间 :O(n)O(n)O(n) ------ 一维 dp 数组

评价

就像一个拼图大师站在高处俯瞰整张桌子,他不急着动手,而是先在心里盘算:"拼到第 5 块时,桌上已有的碎片能不能支撑下去?"他把每个位置的可能性都预先算好,最后只扫一眼终点就知道答案。


五、两种境界对比

境界 算法 核心思想 代码难度 推荐指数
直觉派 记忆化搜索(DFS) 从左到右试,记住失败的经验 ⭐⭐⭐⭐
规划派 动态规划 预先算出每个位置的可能性 ⭐⭐⭐⭐⭐

选哪个?

  • 面试写代码 → 动态规划,代码短、无递归栈风险、最容易一次写对
  • 理解问题本质 → 先学 DFS + Memo,直觉上最自然
  • 实际工程 → 两者都行,DP 略快(无递归开销)

六、举一反三:这类题的识别特征

当你看到这类描述时,就要想到"单词拆分 DP":

  • "给定一个字符串和一个字典"
  • "能否用字典中的单词拼接出该字符串"
  • "单词可以重复使用"
  • 答案是一个 true / false

经典拓展题:

题目 变化点 难度
140. 单词拆分 II 要求返回所有可能的拆分方案 ⭐⭐⭐⭐
472. 连接词 字典中的单词也能被其他单词拼接 ⭐⭐⭐⭐

七、写在最后

这道题最精华的部分,不是 DP 本身,而是那个从 DFS 到 DP 的转化过程:

DFS 是"遇到问题再想办法",DP 是"先把所有可能想好"。

人生也是如此。有人走一步看一步(DFS),遇到死胡同再回头;有人喜欢提前规划(DP),把每个节点的风险都算清楚。两种方法都能走到终点,但后者往往更从容。

预则立,不预则废。


欢迎在评论区分享你的理解,或者指出我表述不清的地方。一起进步!

相关推荐
星马梦缘1 小时前
算法设计导论 课堂笔记
算法·动态规划·分治·时间复杂度·最优二叉树
蓝斯49710 小时前
[原创]《C#高级GDI+实战:从零开发一个流程图》第章:增加贝塞尔曲线,上、下、左、右连接点
java·c#·流程图
前端工作日常10 小时前
我学习到的Java 的 Service 分层:itf 和 impl 到底是什么?
java·后端
都叫我大帅哥11 小时前
Spring @Transactional 注解完全指南
java·spring
Java成神之路-11 小时前
G1 垃圾回收器 :SATB + TAMS核心机制深度解析
java·jvm
前端工作日常11 小时前
我学习到的JIT即时编译与机器码缓存失效
java·后端
带刺的坐椅11 小时前
Solon 的 10 种 HTTP 服务器:改一行依赖,换一个引擎
java·solon·jetty·undertow·mcp-server·htttp
luj_176812 小时前
星火科技助力边远地区防病攻坚
c语言·开发语言·c++·经验分享·算法
xcLeigh12 小时前
Go入门:变量声明的五种方式详解
java·开发语言·golang