343 整数拆分
说实话,第一次看完题解甚至看完答案代码还不能理解在说什么的......
那么先改变一下顺序,从答案开始说起。
因为看不懂代码以及题解在说什么,于是采用debug:打印dp数组,看程序如何运行
以下是根据答案修改后的代码:
public int integerBreak(int n) {
int[] dp = new int[n + 1];
// 初始化
dp[2] = 1;
for (int i = 3; i <= n; i++) {
for (int j = 1; j <= i / 2; j++) {
System.out.println("循环前dp[" + i + "]:" + dp[i]);
System.out.println("i:" + i + " j:" + j + " i - j:" + (i - j));
System.out.println("j*(i - j):" + j * (i - j));
System.out.println("j*dp[i-j]:" + j * dp[i - j]);
dp[i] = Math.max(dp[i], Math.max(j * (i - j), j * dp[i - j]));
System.out.println("循环后dp[" + i + "]为:" + dp[i]);
System.out.println("***********************");
}
}
return dp[n];
}
以10为例子调试如下:(建议从i较大的开始看更直观)
循环前dp[3]:0
i:3 j:1 i - j:2
j*(i - j):2
j*dp[i-j]:1
循环后dp[3]为:2
***********************
循环前dp[4]:0
i:4 j:1 i - j:3
j*(i - j):3
j*dp[i-j]:2
循环后dp[4]为:3
***********************
循环前dp[4]:3
i:4 j:2 i - j:2
j*(i - j):4
j*dp[i-j]:2
循环后dp[4]为:4
***********************
循环前dp[5]:0
i:5 j:1 i - j:4
j*(i - j):4
j*dp[i-j]:4
循环后dp[5]为:4
***********************
循环前dp[5]:4
i:5 j:2 i - j:3
j*(i - j):6
j*dp[i-j]:4
循环后dp[5]为:6
***********************
循环前dp[6]:0
i:6 j:1 i - j:5
j*(i - j):5
j*dp[i-j]:6
循环后dp[6]为:6
***********************
循环前dp[6]:6
i:6 j:2 i - j:4
j*(i - j):8
j*dp[i-j]:8
循环后dp[6]为:8
***********************
循环前dp[6]:8
i:6 j:3 i - j:3
j*(i - j):9
j*dp[i-j]:6
循环后dp[6]为:9
***********************
循环前dp[7]:0
i:7 j:1 i - j:6
j*(i - j):6
j*dp[i-j]:9
循环后dp[7]为:9
***********************
循环前dp[7]:9
i:7 j:2 i - j:5
j*(i - j):10
j*dp[i-j]:12
循环后dp[7]为:12
***********************
循环前dp[7]:12
i:7 j:3 i - j:4
j*(i - j):12
j*dp[i-j]:12
循环后dp[7]为:12
***********************
循环前dp[8]:0
i:8 j:1 i - j:7
j*(i - j):7
j*dp[i-j]:12
循环后dp[8]为:12
***********************
循环前dp[8]:12
i:8 j:2 i - j:6
j*(i - j):12
j*dp[i-j]:18
循环后dp[8]为:18
***********************
循环前dp[8]:18
i:8 j:3 i - j:5
j*(i - j):15
j*dp[i-j]:18
循环后dp[8]为:18
***********************
循环前dp[8]:18
i:8 j:4 i - j:4
j*(i - j):16
j*dp[i-j]:16
循环后dp[8]为:18
***********************
循环前dp[9]:0
i:9 j:1 i - j:8
j*(i - j):8
j*dp[i-j]:18
循环后dp[9]为:18
***********************
循环前dp[9]:18
i:9 j:2 i - j:7
j*(i - j):14
j*dp[i-j]:24
循环后dp[9]为:24
***********************
循环前dp[9]:24
i:9 j:3 i - j:6
j*(i - j):18
j*dp[i-j]:27
循环后dp[9]为:27
***********************
循环前dp[9]:27
i:9 j:4 i - j:5
j*(i - j):20
j*dp[i-j]:24
循环后dp[9]为:27
***********************
循环前dp[10]:0
i:10 j:1 i - j:9
j*(i - j):9
j*dp[i-j]:27
循环后dp[10]为:27
***********************
循环前dp[10]:27
i:10 j:2 i - j:8
j*(i - j):16
j*dp[i-j]:36
循环后dp[10]为:36
***********************
循环前dp[10]:36
i:10 j:3 i - j:7
j*(i - j):21
j*dp[i-j]:36
循环后dp[10]为:36
***********************
循环前dp[10]:36
i:10 j:4 i - j:6
j*(i - j):24
j*dp[i-j]:36
循环后dp[10]为:36
***********************
循环前dp[10]:36
i:10 j:5 i - j:5
j*(i - j):25
j*dp[i-j]:30
循环后dp[10]为:36
***********************
看完之后就豁然开朗了~
以i = 10为例结合代码,原来外部循环就是计算dp[i]对应的值,而j则是从1开始试
- 【j * (i - j)】 - 两个元素
- 【j * dp[i - j]】 - 两个以上元素
- dp[i]
这三个赋值最大的一个值给dp[i]
常见问题:
- 为什么dp[i]要与dp[i]进行比较?我们求的不就是dp[i]吗?
这个问题debug自然就知晓答案。在同一个 i 循环内,j不断变化,dp[i]就需要更换更大的的值。如果求的dp[i]更小那么就保留不动,如果求的dp[i]更大则取最大值
- 为什么比较的第二个元素不能是【dp[j] * dp[i - j]】而是【j * dp[i - j]】?万一dp[j]有更大的值呢?
如果拆j 就是dp[j]的话 拆成的其中一个数就可以看成本次循环之前某一个j的情况(因为一旦拆了就肯定小于j)
而之前某一个j 是j从1到i / 2的情况都考虑了,所以dp[j]肯定被之前的包括。
因此 j * dp[i - j] 就是寻找有无更大的数以更新d[i]
dp[j] * dp[i - j] 不可以。因为j拆分成之前的某个数,就考虑了之前的情况了,而没有包括j本身。
例如:
循环前dp[8]:12
i:8 j:2 i - j:6
j*(i - j):12
d[j]*dp[i-j]:9
j * d[i - j]:18
循环后正确dp[8]为:18
弄懂了就豁然开朗了~
接下来再思考如何想代码:
思路
动规五部曲,分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i]:数字 i 的最大乘积为dp[i]。
- 确定递推公式
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
- dp的初始化
严格从dp[i]的定义来说,
0 + 0 = 0 - dp[0] = 0 * 0 = 0
0 + 1 = 1 - dp[1] = 0 * 1 = 0
1 + 1 = 2 - dp[2] = 1 * 1 = 2
- 确定遍历顺序
确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
所以遍历顺序为:
for (int i = 3; i <= n ; i++) {
for (int j = 1; j < i - 1; j++) {
dp[i] = Math.max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
注意 枚举j的时候,是从1开始的。从0开始的话,那么让拆分一个数拆个0,求最大乘积就没有意义了。
j的结束条件是 j <= i / 2 ,因为 j 到了 i / 2之后枚举就重复了(4 * 6 与 6 * 4)
代码如下:
for (int i = 3; i <= n ; i++) {
for (int j = 1; j <= i / 2; j++) {
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。
例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。
只不过我们不知道m究竟是多少而已,他可能是两个也可能三个也可能...就像6 拆成 【3 3】而10不是拆成【5 5】而是【3 3 4】
但我们仍然明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。
至于 “拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的” 简单证明一下:假设 m n都是5 如果一个+1 一个-1
如上图会发现加减了后会更小 接着运用不等式证明:
- 举例推导dp数组
举例当n为10 的时候,dp数组里的数值,如下:
以上动规五部曲分析完毕,代码如下:
public int integerBreak(int n) {
int[] dp = new int[n + 1];
// 初始化
dp[2] = 1;
for (int i = 3; i <= n; i++) {
for (int j = 1; j <= i / 2; j++) {
dp[i] = Math.max(dp[i] ,Math.max(j * (i - j), j * dp[i - j]));
}
}
return dp[n];
}
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
96 不同的二叉搜索树
思路
先举几个例子,画画图,看看有没有什么规律,如图:
n为1的时候有一棵树,n为2有两棵树,这个是很直观的。
来看看n为3的时候,有哪几种情况。
- 当1为头结点的时候,其右子树有两个节点,看这两个节点的布局,发现和 n 为2的时候两棵树的布局是一样的!
(会发现这节点数值都不一样。别忘了我们就是求不同树的数量,并不用把搜索树都列出来,所以不用关心其具体数值的差异)
- 当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,发现和n为2的时候两棵树的布局也是一样的!
- 当2为头结点的时候,其左右子树都只有一个节点,发现布局和n为1的时候只有一棵树的布局也是一样的!
发现到这里,其实我们就找到了重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。
想到这里,这道题目就有眉目了。
dp[3] = 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
- 元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
- 元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
- 元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
发一下以0 1 2为根节点的二叉搜素树会发现
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
如图所示:
此时我们已经找到递推关系了,那么可以用动规五部曲再系统分析一遍。
- 确定dp数组(dp table)以及下标的含义
dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
- 确定递推公式
在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
j相当于是头结点的元素,从1遍历到i为止。
所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
3. dp数组如何初始化
初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。
那么dp[0]应该是多少呢?
从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。
从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
所以初始化dp[0] = 1
- 确定遍历顺序
首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。
那么遍历i里面每一个数作为头结点的状态,用j来遍历。
代码如下:
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= i; j++) {
dp[i] += dp[j - 1] * dp[i - j];
}
}
- 举例推导dp数组
n为5时候的dp数组状态如图:
综上分析完毕,代码如下:
public int numTrees(int n) {
int[] dp = new int[n + 1];
// 初始化
dp[0] = 1;
for (int i = 1;i <= n; i++){
// 头节点为1 2 3 ...遍历
for (int j = 1; j <= i;j++){
// 递推公式
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
- 时间复杂度:
- 空间复杂度:
总结
这道题目虽然在力扣上标记是中等难度,但可以算是困难了!
首先这道题想到用动规的方法来解决,就不太好想,需要举例,画图,分析,才能找到递推的关系。
然后难点就是确定递推公式了,如果把递推公式想清楚了,遍历顺序和初始化,就是自然而然的事情了。
可以看出本题即使困难但依然还是用动规五部曲来进行分析,会把题目的方方面面都覆盖到!
学习资料: