LC139.单词拆分题解(完全背包-排列问题)

46 阅读7分钟

通常的讲解中,动态规划问题直接从一维 dp 数组开始,然而对于其具体的细节与原理的探讨不够透彻,导致理解困难。本文详细讲解了二维 dp 数组解法,有助于理解为什么是“先物品后背包”等细节问题。

1. 题目

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

链接:leetcode.cn/problems/wo…

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() <= jfalse,直接继承上一行的值,也就是 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];
	}
}