羊羊刷题笔记Day41/60 | 第九章 动态规划P3 | 343. 整数拆分、96.不同的二叉搜索树

98 阅读7分钟

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   j1   i - j:2
		j*(i - j):2
		j*dp[i-j]:1
		循环后dp[3]为:2
		***********************
		循环前dp[4]:0
		i:4   j1   i - j:3
		j*(i - j):3
		j*dp[i-j]:2
		循环后dp[4]为:3
		***********************
		循环前dp[4]:3
		i:4   j2   i - j:2
		j*(i - j):4
		j*dp[i-j]:2
		循环后dp[4]为:4
		***********************
		循环前dp[5]:0
		i:5   j1   i - j:4
		j*(i - j):4
		j*dp[i-j]:4
		循环后dp[5]为:4
		***********************
		循环前dp[5]:4
		i:5   j2   i - j:3
		j*(i - j):6
		j*dp[i-j]:4
		循环后dp[5]为:6
		***********************
		循环前dp[6]:0
		i:6   j1   i - j:5
		j*(i - j):5
		j*dp[i-j]:6
		循环后dp[6]为:6
		***********************
		循环前dp[6]:6
		i:6   j2   i - j:4
		j*(i - j):8
		j*dp[i-j]:8
		循环后dp[6]为:8
		***********************
		循环前dp[6]:8
		i:6   j3   i - j:3
		j*(i - j):9
		j*dp[i-j]:6
		循环后dp[6]为:9
		***********************
		循环前dp[7]:0
		i:7   j1   i - j:6
		j*(i - j):6
		j*dp[i-j]:9
		循环后dp[7]为:9
		***********************
		循环前dp[7]:9
		i:7   j2   i - j:5
		j*(i - j):10
		j*dp[i-j]:12
		循环后dp[7]为:12
		***********************
		循环前dp[7]:12
		i:7   j3   i - j:4
		j*(i - j):12
		j*dp[i-j]:12
		循环后dp[7]为:12
		***********************
		循环前dp[8]:0
		i:8   j1   i - j:7
		j*(i - j):7
		j*dp[i-j]:12
		循环后dp[8]为:12
		***********************
		循环前dp[8]:12
		i:8   j2   i - j:6
		j*(i - j):12
		j*dp[i-j]:18
		循环后dp[8]为:18
		***********************
		循环前dp[8]:18
		i:8   j3   i - j:5
		j*(i - j):15
		j*dp[i-j]:18
		循环后dp[8]为:18
		***********************
		循环前dp[8]:18
		i:8   j4   i - j:4
		j*(i - j):16
		j*dp[i-j]:16
		循环后dp[8]为:18
		***********************
		循环前dp[9]:0
		i:9   j1   i - j:8
		j*(i - j):8
		j*dp[i-j]:18
		循环后dp[9]为:18
		***********************
		循环前dp[9]:18
		i:9   j2   i - j:7
		j*(i - j):14
		j*dp[i-j]:24
		循环后dp[9]为:24
		***********************
		循环前dp[9]:24
		i:9   j3   i - j:6
		j*(i - j):18
		j*dp[i-j]:27
		循环后dp[9]为:27
		***********************
		循环前dp[9]:27
		i:9   j4   i - j:5
		j*(i - j):20
		j*dp[i-j]:24
		循环后dp[9]为:27
		***********************
		循环前dp[10]:0
		i:10   j1   i - j:9
		j*(i - j):9
		j*dp[i-j]:27
		循环后dp[10]为:27
		***********************
		循环前dp[10]:27
		i:10   j2   i - j:8
		j*(i - j):16
		j*dp[i-j]:36
		循环后dp[10]为:36
		***********************
		循环前dp[10]:36
		i:10   j3   i - j:7
		j*(i - j):21
		j*dp[i-j]:36
		循环后dp[10]为:36
		***********************
		循环前dp[10]:36
		i:10   j4   i - j:6
		j*(i - j):24
		j*dp[i-j]:36
		循环后dp[10]为:36
		***********************
		循环前dp[10]:36
		i:10   j5   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   j2   i - j:6
		j*(i - j):12
		d[j]*dp[i-j]:9
  	j * d[i - j]:18
		循环后正确dp[8]为:18

弄懂了就豁然开朗了~

接下来再思考如何想代码:

思路

动规五部曲,分析如下:

  1. 确定dp数组(dp table)以及下标的含义

dp[i]:数字 i 的最大乘积为dp[i]。

  1. 确定递推公式

其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。

  1. 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

  1. 确定遍历顺序

确定遍历顺序,先来看看递归公式: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 Screenshot_20230807_214213_com.newskyer.draw.png 如上图会发现加减了后会更小 接着运用不等式证明: Screenshot_20230807_214815_com.newskyer.draw.png

  1. 举例推导dp数组

举例当n为10 的时候,dp数组里的数值,如下:
image.png
以上动规五部曲分析完毕,代码如下:

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 不同的二叉搜索树

思路

先举几个例子,画画图,看看有没有什么规律,如图:
image.png
n为1的时候有一棵树,n为2有两棵树,这个是很直观的。
image.png
来看看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]
如图所示:
image.png
此时我们已经找到递推关系了,那么可以用动规五部曲再系统分析一遍。

  1. 确定dp数组(dp table)以及下标的含义

dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]

  1. 确定递推公式

在上面的分析中,其实已经看出其递推关系, 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

  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];
    }
}
  1. 举例推导dp数组

n为5时候的dp数组状态如图:
image.png
综上分析完毕,代码如下:

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];
        }
  • 时间复杂度:O(n2)O(n^2)
  • 空间复杂度:O(n)O(n)

总结

这道题目虽然在力扣上标记是中等难度,但可以算是困难了!
首先这道题想到用动规的方法来解决,就不太好想,需要举例,画图,分析,才能找到递推的关系
然后难点就是确定递推公式了,如果把递推公式想清楚了,遍历顺序和初始化,就是自然而然的事情了。
可以看出本题即使困难但依然还是用动规五部曲来进行分析,会把题目的方方面面都覆盖到!

学习资料:

343. 整数拆分

96.不同的二叉搜索树