day48学习内容
day48主要内容
- 打家劫舍-相邻的不能偷
- 打家劫舍(环形)-相邻的不能偷。首尾也不能偷
- 打家劫舍(树形)-相邻的子节点不能偷
声明
本文思路和文字,引用自《代码随想录》
一、打家劫舍-相邻的不能偷
1.1、动态规划五部曲
1.1.1、 确定dp数组(dp table)以及下标的含义
- dp[i]表示到第 i 间房屋时能够盗取的最大金额。
1.1.2、确定递推公式
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
请看后面,写了这个公式是怎么分析出来的。
1.1.3、 dp数组如何初始化
为了使 dp
数组能够正确处理前两间房屋的情况,需要设置初始条件:
dp[0] = nums[0]
,即如果只有一间房屋,最大盗窃金额就是这间房屋的金额。总不能不偷把,不偷还叫什么求最大值。dp[1] = max(nums[0], nums[1])
,即如果有两间房屋,最大盗窃金额是这两间房屋中金额较大的那个。
1.1.4、确定遍历顺序
遍历顺序,就是先遍历物品
1.2、代码
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(dp[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[nums.length - 1];
}
}
1.2.1、转态转移方程是怎么推导出来的
思路
-
定义状态:首先,定义
dp[i]
表示到达第i
间房屋时,不触发警报条件下所能盗窃到的最大金额。dp[i]
的值取决于前面房屋的状态以及当前房屋的价值。 -
考虑选择:对于每一间房屋
i
,小偷有两个选择:- 不盗窃当前房屋:如果小偷选择不盗窃当前房屋,那么他能够保持的最大金额就是到达前一间房屋时的最大金额,即
dp[i-1]
。 - 盗窃当前房屋:如果小偷选择盗窃当前房屋,由于不能连续盗窃两间相邻的房屋,他将不能盗窃前一间房屋,但可以盗窃前前间房屋,然后加上当前房屋的金额,即
dp[i-2] + nums[i]
。
- 不盗窃当前房屋:如果小偷选择不盗窃当前房屋,那么他能够保持的最大金额就是到达前一间房屋时的最大金额,即
-
确定转移方程:小偷在每一间房屋的决策目标是最大化盗窃金额,因此,到达第
i
间房屋时所能盗窃到的最大金额是上述两种选择中的最大值:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
-
初始条件:为了使
dp
数组能够正确处理前两间房屋的情况,需要设置初始条件:dp[0] = nums[0]
,即如果只有一间房屋,最大盗窃金额就是这间房屋的金额。dp[1] = max(nums[0], nums[1])
,即如果有两间房屋,最大盗窃金额是这两间房屋中金额较大的那个。
-
终点:遍历完所有房屋后,
dp
数组的最后一个元素dp[nums.length - 1]
就是在给定条件下能够盗窃到的最大金额。
1.2.2、这题和动态规划有什么关系?
-
“背包”:在这个比喻中,“背包”的容量可以被看作是小偷的盗窃行为的可能性或盗窃决策的容量。每一次决策(是否盗窃某间房屋)都受到前一个决策的影响(因为不能连续盗窃相邻的房屋)。因此,“背包”的容量不是指实际的物理容量,而是指在不触发警报的情况下,小偷能够盗窃房屋的“策略空间”或决策能力。
-
“物品”:在这个场景中,每间房屋及其内部的现金可以被视为“物品”。每件“物品”的价值就是房屋中的现金数量。小偷面对的决策就是是否选择“拿走”这件物品——即是否盗窃这间房屋。每件物品(房屋)的选择都受到一个约束条件的影响,即不能选择相邻的物品,这是因为相邻房屋的盗窃会触发警报。
关键区别
虽然“打家劫舍”问题可以用“背包”和“物品”的概念进行类比,但它与传统背包问题(如0-1背包问题或完全背包问题)在结构和约束上有本质的不同:
- 在0-1背包问题中,背包有一个固定的容量限制,而“物品”有各自的重量和价值。目标是选择一组物品,使得总重量不超过背包容量,同时总价值最大化。
- 在“打家劫舍”问题中,不存在物理容量限制。相反,约束条件是基于选择的序列性质(不能连续选择相邻的房屋)。
因此,尽管“打家劫舍”问题可以通过与背包问题类似的动态规划方法来解决,其具体的逻辑和实现细节有着明显的差异。
二、打家劫舍(环形)-相邻的不能偷。首尾也不能偷,因为首尾相连。
2.1、动态规划五部曲
2.1.1、 确定dp数组(dp table)以及下标的含义
-
在这段代码中,虽然没有直接使用
dp[i]
这样的数组来存储结果,但我们可以从逻辑上推导出类似的dp
数组的含义,这有助于理解动态规划的过程。 -
如果我们构建一个
dp
数组,其中dp[i]
表示在考虑到第i
个房子(从0开始计数)时,能够偷窃到的最大金额,那么:dp[i]
代表包括第i
间房子在内,从第0间到第i
间房子能够偷窃到的最大金额。- 状态转移方程就会是:
dp[i]=max(dp[i−1],dp[i−2]+nums[i])
dp[i-1]
表示不偷第i
间房子的情况,此时的最大金额即为到第i-1
间房子为止能偷窃到的最大金额。dp[i-2] + nums[i]
表示偷第i
间房子的情况,此时的最大金额为到第i-2
间房子为止能偷窃到的最大金额加上第i
间房子的金额。
-
在这段代码中,使用
newMax
(相当于dp[i]
)、currentMax
(相当于dp[i-1]
)和preHouseMax
(相当于dp[i-2]
)三个变量就足以追踪这些状态,无需维护完整的dp
数组,这样做减少了空间复杂度。这种方法通常被称为动态规划的空间优化。
2.1.2、确定递推公式
newMax
表示在考虑到当前房屋i时,能够获得的最大金额。
currentMax
表示不包括当前房屋i,直到房屋i-1时能获得的最大金额。
preHouseMax
表示直到房屋i-2时能获得的最大金额。
newMax=max(currentMax,preHouseMax+nums[i])
这里的逻辑是:对于当前房屋i,有两种选择:
- 不偷当前房屋,此时最大金额为currentMax(即上一个房屋的最大金额)。
- 偷当前房屋,此时最大金额为preHouseMax + nums[i](即上上个房屋的最大金额加上当前房屋的金额)。
- 对于每个房屋,我们需要决定是偷还是不偷,以使得总金额最大化,而这个决策基于上述的状态转移方程。最终,newMax将存储到达当前房屋时可能获得的最大金额。
2.1.3、 dp数组如何初始化
在这段代码中,初始化是通过直接设置preHouseMax
和currentMax
的值来完成的。逻辑如下:
preHouseMax = 0
:这表示在开始迭代之前,没有考虑任何房屋时的最大金额是0。这相当于dp[-2]
的概念,如果我们使用一个完整的dp
数组来表示,即在还没有开始偷窃时的初始状态。currentMax = 0
:这同样表示在开始迭代之前,当考虑的房屋数量为0时的最大金额也是0。这相当于dp[-1]
的概念,在动态规划的上下文中,这代表在考虑第一个元素之前的初始状态。
2.1.4、确定遍历顺序
从数组nums的start位置开始,直到end位置结束,但不包括end位置本身。
-
for (int i = start; i < end; i++)
:这个循环会从start
索引开始遍历,一直遍历到end - 1
的位置。在每次迭代中,它会根据前面计算的newMax
和preHouseMax
来更新当前可以偷窃到的最大金额。 -
在遍历过程中,
i
是当前考虑的房屋的索引。对于每个i
,算法会计算包括该房屋和不包括该房屋时的最大偷窃金额,并更新preHouseMax
和currentMax
。 -
这种遍历顺序使得每次计算都是基于前面的结果进行的,符合动态规划的特性,即解决当前问题时依赖于之前子问题的解。
2.2、代码
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0)
return 0;
int housesCount = nums.length;
if (housesCount == 1)
return nums[0];
// 分两种情况处理,一种是不包括最后一间房子,一种是不包括第一间房子
return Math.max(robMaxAmount(nums, 0, housesCount - 1), robMaxAmount(nums, 1, housesCount));
}
int robMaxAmount(int[] nums, int start, int end) {
// 表示直到房屋i-2时能获得的最大金额。
int preHouseMax = 0;
// 表示不包括当前房屋i,直到房屋i-1时能获得的最大金额。
int currentMax = 0;
// 表示在考虑到当前房屋i时,能够获得的最大金额。
int newMax = 0;
for (int i = start; i < end; i++) {
currentMax = newMax;
// 计算当前房屋盗窃与否的最大金额
newMax = Math.max(currentMax, preHouseMax + nums[i]);
preHouseMax = currentMax;
}
return newMax;
}
}
2.3、另一种更好理解的代码
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int housesCount = nums.length;
if (housesCount == 1) {
return nums[0];
}
// 分两种情况处理,一种是不包括最后一间房子,一种是不包括第一间房子
return Math.max(robMaxAmount(nums, 0, housesCount - 1), robMaxAmount(nums, 1, housesCount));
}
int robMaxAmount(int[] nums, int start, int end) {
if (end - start <= 1) {
return nums[start];
}
int[] dp = new int[end];
// 初始化dp数组
dp[start] = nums[start];
dp[start + 1] = Math.max(nums[start], nums[start + 1]);
for (int i = start + 2; i < end; i++) {
// 动态规划转移方程
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[end - 1];
}
}
这里,dp[i]
的定义是考虑到第i
个房子时(包括第i
个房子)能够获得的最大金额。dp[i - 1]
代表不偷第i
个房子时的最大金额,而dp[i - 2] + nums[i]
则代表偷第i
个房子时的最大金额。我们通过比较这两个值来决定dp[i]
的值。这种方式清晰地表达了动态规划的状态转移关系。
三、打家劫舍(树形)-相邻的子节点不能偷
337.原题链接
这鬼题目是什么意思?
这里的房子是按照二叉树的形式排列的,每个节点代表一个房子,节点的值代表该房子中的财物数量。规则是不能连续偷窃两个直接相连的房子,即如果偷了一个节点,就不能偷其子节点。
人话讲就是线连在一起的,就不能偷。
3.1、动态规划五部曲
3.1.1、 确定dp数组(dp table)以及下标的含义
- 在这个树形结构的动态规划问题中,直接使用
dp[i]
来表示状态可能会有些混淆,因为我们不是在处理一个线性数组,而是在处理一个二叉树。在二叉树的动态规划问题中,通常我们不会使用dp[i]
这样的表示,因为节点的索引不像数组中那样是线性的。
然而,如果我们要将这个逻辑映射到类似dp[i]
的表示上,我们可以这样考虑:
dp[node][0]
代表不包含节点node
的子树中能够获得的最大金额(即不偷node
节点时的最大金额)。dp[node][1]
代表包含节点node
的子树中能够获得的最大金额(即偷node
节点时的最大金额)。
这里的node
代表树中的任意一个节点,而不是一个线性的索引。dp[node][0]
和dp[node][1]
反映了以node
为根的子树在不同决策下的最大收益。这种表示方式更适合描述在树结构上的动态规划问题,而不是简单地使用dp[i]
,因为在树上没有顺序的线性关系。
3.1.2、确定递推公式
在这种写法中,递推公式体现在如何从子节点的状态推导出当前节点的状态。对于任意一个节点,我们有两种状态:偷该节点或不偷该节点。我们用一个数组res
来表示这两种状态,其中res[0]
表示不偷该节点时的最大金额,而res[1]
表示偷该节点时的最大金额。这两种状态的递推公式如下:
- 不偷当前节点(
res[0]
): 如果当前节点不被偷,那么左右子节点可以选择偷或不偷,取决于哪种选择能获得更大的金额。因此,当前节点的res[0]
等于左子节点的最大值(Math.max(left[0], left[1])
)加上右子节点的最大值(Math.max(right[0], right[1])
)。
res[0]=max(left[0],left[1])+max(right[0],right[1])
- 偷当前节点(
res[1]
): 如果当前节点被偷,那么两个子节点都不能被偷。因此,当前节点的res[1]
等于当前节点的值root.val
加上两个子节点不被偷时的金额(left[0] + right[0]
)。
res[1]=root.val+left[0]+right[0]
这两个递推公式共同定义了在树形动态规划问题中,如何根据子节点的状态来确定当前节点的状态,从而确保可以从底向上地计算出每个节点在被偷或不被偷两种情况下可能获得的最大金额。
3.1.3、 dp数组如何初始化
在这段代码中,动态规划的初始化体现在处理空节点(null
)的情况。当递归到一个空节点时,该节点的robAction
函数返回一个包含两个零元素的数组res
。这个返回值实际上代表了初始化步骤,因为它定义了边界条件:当一个节点不存在时,不管是选择偷还是不偷,可获得的金额都是0。这是动态规划中非常典型的边界条件设置。
if (root == null)
return new int[2]; // 返回{0, 0},表示对于空节点,无论偷还是不偷,金额都是0
这个初始化确保了在递归过程中,当达到叶节点的子节点(即null
节点)时,递归能够返回一个基础值,然后递归回溯,逐层向上累加计算。每个节点的res
数组都是基于其子节点的返回值计算得到的,而子节点的返回值又依赖于更下一层的子节点,直到达到空节点,空节点的返回值直接初始化为{0, 0}
,这为整个递归过程提供了基础的累加起点。
3.1.4、确定遍历顺序
这种写法采用的是二叉树的后序遍历,即先访问左子节点,再访问右子节点,最后处理当前节点。这种遍历顺序非常适合动态规划在树结构上的应用,因为它确保在处理当前节点前,其所有子节点已经被处理,使得当前节点的计算可以基于其子节点的结果。
-
递归访问左子节点: 递归调用
robAction(root.left)
,这会深入到左子树,继续递归直到达到叶节点的左子树为空节点,然后返回并开始计算叶节点的左子树的值(即初始化的{0, 0})。 -
递归访问右子节点: 完成左子树的处理后,代码通过
robAction(root.right)
递归调用处理右子树,采用同样的方式深入到右子树的最底部,然后逐步返回并计算。 -
处理当前节点: 当左右子节点都处理完毕后,计算当前节点的两个状态值。
res[0]
(不偷当前节点)基于左右子节点偷或不偷的最大值之和,而res[1]
(偷当前节点)基于当前节点的值加上左右子节点不被偷时的金额。
这种后序遍历确保了在计算每个节点的值时,其所有子树的最优解都已经计算完成,这是动态规划在树形结构上应用的关键之处。通过这种方式,算法自底向上地解决了每个子问题,最终达到根节点时就得到了整个问题的最优解。
3.2、代码
class Solution {
// 从根节点开始处理
public int rob(TreeNode root) {
int[] res = robAction(root);
// 最终结果是偷或不偷根节点中的最大值
return Math.max(res[0], res[1]);
}
// 辅助递归函数,返回一个包含两个元素的数组
// 其中 res[0] 表示不偷当前节点所能获得的最大金额,res[1] 表示偷当前节点能获得的最大金额
int[] robAction(TreeNode root) {
int res[] = new int[2];
// 空节点的情况,偷或不偷都是0
if (root == null)
return res;
// 递归处理左子树和右子树
int[] left = robAction(root.left);
int[] right = robAction(root.right);
// 不偷当前节点时,可以选择偷或不偷其子节点,取决于哪种选择能得到更大的金额
res[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
// 偷当前节点时,加上当前节点的值,并加上不偷左右子节点时的金额
res[1] = root.val + left[0] + right[0];
// 返回当前节点的结果
return res;
}
}
3.2.1、这段代码,动态规划体现在哪里
这段代码是解决一个树形结构上的动态规划问题,具体是在一个二叉树上进行打家劫舍。二叉树的每个节点代表一个房子,节点的值代表该房子中的财物数量。规则是不能连续偷窃两个直接相连的房子,即如果偷了一个节点,就不能偷其子节点。
这里的动态规划体现在递归函数robAction
中,它返回一个包含两个元素的数组res
,其中:
res[0]
表示不偷当前节点(root)能得到的最大金额(即偷当前节点的子节点)。res[1]
表示偷当前节点能得到的最大金额。
在递归过程中,每个节点的最大偷窃金额基于其子节点的最大偷窃金额来计算,具体如下:
- 对于
res[0]
(不偷当前节点),最大金额是其左右子节点偷或不偷时金额的较大值之和。这表示如果当前节点不被偷,那么其左右子节点可以选择偷或不偷,取决于哪种选择能获得更大的金额。 - 对于
res[1]
(偷当前节点),最大金额是当前节点的值加上其左右子节点不被偷时的金额。这表示如果当前节点被偷,那么其左右子节点都不能被偷。
这种方法通过自底向上的方式(后序遍历),在每个节点处考虑偷与不偷两种情况,并基于子节点的计算结果来确定当前节点的最优决策。通过递归,它实现了从叶子节点向根节点逐步建立最优解的过程,这是动态规划在树形结构上的体现。每个节点的计算只依赖于其直接子节点的结果,避免了重复计算,从而优化了时间复杂度。
3.2.2、怎么理解偷或者不偷
这里的数组res
、left
和right
都有两个元素,分别代表两种状态:
res[0]
、left[0]
和right[0]
分别表示不偷当前节点、不偷左子节点和不偷右子节点时,各自能获得的最大金额。res[1]
、left[1]
和right[1]
分别表示偷当前节点、偷左子节点和偷右子节点时,各自能获得的最大金额。
因此,在计算res[0]
时(即不偷当前节点的情况下),你需要考虑的是左子节点和右子节点被偷或不被偷的情况下,能得到的最大金额。这就是为什么在计算res[0]
时使用了Math.max(left[0], left[1]) + Math.max(right[0], right[1])
,它表示左子节点和右子节点分别取偷或不偷的最大值,然后相加。
总结
1.感想
- 打家劫舍(树形)好难,一开始题意都没看懂。。
2.思维导图
本文思路引用自代码随想录,感谢代码随想录作者。