通常的讲解中,动态规划问题直接从一维 dp 数组开始,然而对于其具体的细节与原理的探讨不够透彻,导致理解困难。本文详细讲解了二维 dp 数组解法,有助于理解为什么是“先物品后背包”等细节问题。
1. 题目
给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
2. 题解
a. 定义 dp 数组
定义dp[i][j]
,表示从 wordDict
前i
个字符中任选,能否拼接成 s.substring(0, j)
。请注意,s.substring(0, j)
方法截取的是 s.charAt(0) ~ s.charAt(j - 1)
的子串,不包含 s.charAt(j)
。
定义辅助数组 p[i][j]
,表示以 wordDict
中第 i
个字符串结尾(也就是 wordDict.get(i - 1)
),能否拼接成 s.substring(0, j)
。
ⅰ. dp[i][j]
与p[i][j]
的关系?
很简单理解,
dp[i][j] = p[1][j] || p[2][j] || p[3][j] || .... || p[i - 1][j] || p[i][j]
对于任意 k
,满足条件 1 <= k <= j
,只要存在一个k
,使得p[k][j]
为true
,那么 dp[i][j]
肯定为 true
,所以容易推出dp[i][j]
与p[i][j]
之间的关系。
ⅱ. dp[i][j] 与 dp[i - 1][j]
的关系?
因为动态规划要的入手点是推导相邻状态之间的递推公式,所以开始思考 dp[i][j] 与 dp[i - 1][j]
的关系。
容易得到,
dp[i - 1][j] = p[1][j] || p[2][j] .... || p[i - 2][j] || p[i - 1][j]
所以,
dp[i][j] = dp[i - 1][j] || p[i][j]
ⅲ. 推导 p[i][j]
回到辅助数组 p[i][j]
原本的含义:以 wordDict
中第 i
个字符串结尾,能否拼接成 s.substring(0, j)
。
从定义出发,p[i][j]
的值取决于两个因素:
- 第
i
个字符串是否匹配s.substring(0, j)
的结尾; s.substring(j - wordDict.get(i - 1).length(), j)
能否被顺利拼接?思路突然卡了一下,能不能顺利拼接也得基于可以选哪些字符串吧?回到辅助数组p[i][j]
原本的定义,只要求了最后一个字符串,没规定之前可以用哪些字符串,所以说可以从wordDict
中的字符串中任选,结合 dp 数组 的定义,正好可以用dp[m][j - wordDict.get(i - 1).length()
表示。
两者很明显是 &&
的关系,对于wordDict
中第 i
个字符串:String str = wordDict.get(i - 1);
用代码语言表达:
s.substring(j - wordDict.get(i - 1).length(), j).equal(str) && dp[m][j - str.length());
ⅳ. 推导递推公式
综合 i、ii、iii,
dp[i][j] = dp[i - 1][j] || (s.substring(j - wordDict.get(i - 1).length(), j).equal(str) && dp[m][j - str.length()));
b. 初始化 dp 数组
具体如何初始化依赖于递推公式,递推顺序等等,之后再讨论,这里先简单讨论一下dp[0][0]
的初始化。i = 0
说明没法选字符串,j = 0
说明要求拼接成一个空字符串,这种情况非常契合,所以我们定义 dp[0][0] = true
。
然而,这里可能会引发一些疑问,题目明明说了1 <= s.length <= 300
,不可能有空字符串!那么为什么在没有选择字符串且拼接的字符串为空的情况下,结果居然是 true
呢?这似乎缺乏逻辑性。
虽然我无法从严谨的逻辑上解释这种现象,但可以试着从直觉的角度来理解。我们在解题时,实际上是在构建一个自洽的框架,即一套普遍适用的方法。通过运行一些简单的样例,或者进行个别推导,我们就能够生成某些可以反映问题共性的结构,这本身就是一种神奇的过程。
有人假想自然界中是否存在某种可以解释万物的真理,我更愿意把这种真理想象成一个动态的过程,它由一系列基本公理组成,它由一系列基本公理构成,并严谨地遵循着某些规则。就像博尔赫斯在《巴别塔图书馆》中提到的那本可以解释所有书籍的总和之书一样。我们无需找到那个解释万物的系统,而是只需要在我们解决的问题中,选取一个极小的子集,并创造出或模仿该系统的运行逻辑。
这种系统是由基本的公理和原则构成的,符合某种深层次的直觉,充满着美感。而我们对 dp 和 p 数组的定义,正是这一系统的规则。这些规则通过推导共同构建了一个小型的“真理”。为什么这些规则能够在简单的推导中成功构建?这可能正是那种潜在的真理展现出的神奇之处——我们模仿了它,并创造出令人惊叹的结果,这正是它的奇妙之处。
c. 分析递推公式以及递推顺序
我们在之前定义 dp 数组的时候就已经推导出了递推公式:
dp[i][j] = dp[i - 1][j] || (s.substring(j - wordDict.get(i - 1).length(), j).equal(str) && dp[m][j - str.length()));
我们可以基于这个公式写出代码:
String str = wordDict.get(i - 1);
if (str.length() <= j) {
boolean flag = s.substring(j - wordDict.get(i - 1).length(), j).equal(str);
dp[i][j] = dp[i - 1][j] || (flag && dp[m][j - str.length());
} else {
dp[i][j] = dp[i - 1][j];
}
确认遍历顺序需要确认五个基本信息:先遍历i
还是先遍历j
,从左到右还是从右到左?从上到下还是从下到上?i
的初始值,j
的初始值。
可以看到,推导 dp[i][j]
,需要知道 dp[i - 1][j]
以及 dp[m][j - str.length()]
的值,也就是这俩的顺序必须得在 dp[i][j]
之前。(直观想象,也就是依赖于自己正上方相邻的元素以及左下方的元素)
- 从
dp[i - 1][j]
可以推导出,只需要满足for (i = 1; i <= m; i++)
就行了,里层循环还是外层循环都行。 - 从
dp[m][j - str.length()]
可以看到,我一个第i
行的 dp 数组居然需要前几列最后一行的 dp 数组,很明显可以知道得是“竖着遍历”,也就是for (i = 1; i <= m; i++)
得在内层;最后还有一个问题,是“从上到下”还是“从下到上”呢?如果“从下到上”,会发现在推导dp[i][j]
的时候,dp[i - 1][j]
都还没更新!所以只能是“从上到下”。
然后i
的初始值与j
的初始值和之前的初始化 dp
数组息息相关,很明显需要初始化第一行,然后从第二行开始遍历。至于要不要不初始化第一列呢?可以先自己尝试初始化,然后从第二列开始遍历,然后进一步分析可不可以直接从第一列开始遍历,这样就不需要初始化第一列了。一般这时候可以把j
的初始值改为 0
,然后在脑子里手动运行一遍代码,可以看到j = 0
时,根据题意 str.length() <= j
为false
,直接继承上一行的值,也就是 true
,符合 dp 数组的定义,所以j
的初始值为0
就行了。
综上可以给出代码,
int i, j;
for (j = 0; j <= n; j++) {
for (i = 1; i <= m; i++) {
......
}
}
然后我们回到初始 dp 数组的阶段,因为 java 中定义 boolean 型数组的初始值是 false,正好符合题意,所以只需要初始化 dp[0][0]
就行了
综上,使用二维 dp 数组完整解决了这个问题。
3. 代码
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int m = wordDict.size();
int n = s.length();
// 定义 dp 数组
boolean[][] dp = new boolean[m + 1][n + 1];
int i, j;
// 初始化 dp 数组
dp[0][0] = true;
// 确定递推顺序以及递推公式
for (j = 0; j <= n; j++) {
for (i = 1; i <= m; i++) {
String str = wordDict.get(i - 1);
if (j >= str.length()) {
boolean flag = s.substring(j - str.length(), j).equals(str);
dp[i][j] = dp[i - 1][j] || (flag && dp[m][j - str.length()]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[m][n];
}
}
最后一维 dp 数组解法就是把二维 dp 数组压缩成一行就行了,很容易分析出,在当前递推顺序下,在一维 dp 数组中,dp[j] = dp[i - 1][j]
、dp[j - str.length()] = dp[m][j - str.length()]
。均能取到所需要值,直接上手改就行,下面是一维 dp 数组解法。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int m = wordDict.size();
int n = s.length();
// 定义 dp 数组
boolean[] dp = new boolean[n + 1];
int i, j;
// 初始化 dp 数组
dp[0] = true;
// 确定递推顺序以及递推公式
for (j = 0; j <= n; j++) {
for (i = 1; i <= m; i++) {
String str = wordDict.get(i - 1);
if (j >= str.length()) {
boolean flag = s.substring(j - str.length(), j).equals(str);
dp[j] = dp[j] || (flag && dp[j - str.length()]);
}
}
}
return dp[n];
}
}