前言
动态规划是算法设计中非常重要的一种思想,而"打家劫舍"系列问题则是动态规划的经典例题。本文将详细解析打家劫舍问题的三个变种,帮助读者掌握动态规划的应用技巧。
一、打家劫舍I(基础版)
问题描述:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
text
输入:[1,2,3,1]
输出:4
解释:偷窃第1号房屋(金额=2)和 第3号房屋(金额=3),总金额=2+3=4。
解题思路
这是一个非常经典的动态规划题目,对于动态规划的核心特点是:当前的选择会影响未来的选择。具体来说:
- 如果偷了当前房屋,就不能偷相邻的下一个房屋
- 如果不偷当前房屋,就可以自由选择是否偷下一个房屋
这种特性使得问题具有最优子结构性质:一个问题的最优解包含其子问题的最优解。
所以我们在接下来的分析中都是围绕着偷与不偷展开的,我们用卡哥的动规五部曲:
1.确定dp数组的含义
dp[i]表示在i个房子范围内,能偷窃的最大金额(请默念三遍,时刻牢记它的含义)
2.递推公式
对于每个房子,偷或不偷都可以,我们取偷或不偷两个选择中能得到的最好结果
偷:偷第i个房子的最大结果肯定是不考虑第i-1个房子的,沿用第i-2套房子的状态 (注意,这里第i-2套房子的状态是第i-2套房子范围内能偷的最大金额,不意味着一定要偷第i-2套房子)
dp[i] = dp[i-2] + nums[i]
不偷:不偷第i个房子时,沿用第i-1套房子的状态 (注意:这里第i-1套房子的状态是第i-1套房子范围内能偷的最大金额,不意味着一定要偷第i-1套房子)
dp[i] = dp[i-1]
将两种状态对比,取得最大的一个,就能得到第i个房子范围内,能偷窃的最大金额(dp[i])
最终递推公式: dp[i] = Math.max(dp[i-2]+nums[i] , dp[i-1])
上面我在括号中强调的内容需要注意:dp[i-1]和dp[i-2]代表的是第i-1或第i-2范围内的最好状态,拿i-1来说,它是在经历过从0,1,2...到第i-1套房子的偷与不偷的选择中能够获取的最好结果,我们不需要知道第i-1套房子到底偷没偷,只需要知道,当不偷第i套房子时,我们就不加num[i],沿用第i-1套房子的状态即可!
3.初始化
初始化时我们需要注意两点,递推公式和dp含义:
从递推公式上来说
dp[i] = Math.max(dp[i-2]+nums[i] , dp[i-1])
,递推公式的基础就是dp[0]和dp[1]
从dp含义上来说:
dp[0]
就是nums[0],第0套房子内能偷的最大价值可不就是直接偷仅有的第0家嘛!
dp[1]
则是nums[0]和nums[1]的最大值,也很好理解吧,由于不能偷相邻的,那你就偷一个最大的呗!
所以我们需要在代码最开始将dp[0]和dp[1]正确的初始化
4.遍历顺序
这个就简单了,第i套房子偷与不偷时要沿用前面的状态,所以肯定是从前往后!
5.举例推导dp数组
初始状态
ininums = [2, 7, 9, 3, 1] dp = [0, 0, 0, 0, 0] # 初始化
第0步(i=0)
inidp[0] = nums[0] = 2 dp = [2, 0, 0, 0, 0]
第1步(i=1)
scssdp[1] = max(nums[0], nums[1]) = max(2,7) = 7 dp = [2, 7, 0, 0, 0]
第2步(i=2)
不偷第2个房屋:保持
dp[1] = 7
偷第2个房屋:
dp[0] + nums[2] = 2 + 9 = 11
取较大值:
scssdp[2] = max(7, 11) = 11 dp = [2, 7, 11, 0, 0]
第3步(i=3)
- 不偷第3个房屋:保持
dp[2] = 11
- 偷第3个房屋:
dp[1] + nums[3] = 7 + 3 = 10
取较大值:
scssdp[3] = max(11, 10) = 11 dp = [2, 7, 11, 11, 0]
第4步(i=4)
不偷第4个房屋:保持
dp[3] = 11
偷第4个房屋:
dp[2] + nums[4] = 11 + 1 = 12
取较大值:
scssdp[4] = max(11, 12) = 12 dp = [2, 7, 11, 11, 12]
最终结果
dp
数组的最后一个值就是最大金额:
inimax_amount = dp[4] = 12
完整代码:
最终我们得到的代码如下:
js
var rob = function(nums) {
const len = nums.length;
//确定dp以及初始化
const dp = [nums[0],Math.max(nums[0],nums[1])];
//递推
for(let i = 2 ; i < len ; i ++)
{
dp[i] = Math.max(dp[i-1],dp[i-2]+nums[i]);
}
return dp[len-1];
};
二、打家劫舍II(环形数组)
问题描述:在打家劫舍I的基础上,房屋现在排列成一个圆圈,即第一个房屋和最后一个房屋是相邻的。
示例:
text
输入:[2,3,2]
输出:3
解释:你不能先偷窃第1号房屋(金额=2),然后偷窃第3号房屋(金额=2),因为它们是相邻的。
解题思路
环形排列意味着第一个和最后一个房屋不能同时被偷窃。因此,我们可以将问题分解为两个子问题:
- 不偷窃最后一个房屋,计算nums[0:n-1]的最大金额
- 不偷窃第一个房屋,计算nums[1:n]的最大金额
然后取这两个子问题的较大值作为最终结果。
对于每个子问题,其实就是一个打家劫舍1,我们将打家劫舍1的解法封装成函数robRange
即可
完整代码如下:
js
var rob = function(nums) {
const n = nums.length
if (n === 0) return 0
if (n === 1) return nums[0]
const result1 = robRange(nums, 0, n - 2)
const result2 = robRange(nums, 1, n - 1)
return Math.max(result1, result2)
};
const robRange = (nums, start, end) => {
if (end === start) return nums[start]
//定义dp数组
const dp = Array(nums.length).fill(0)
//初始化
dp[start] = nums[start]
dp[start + 1] = Math.max(nums[start], nums[start + 1])
//递推
for (let i = start + 2; i <= end; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])
}
return dp[end]
}
三、打家劫舍III(二叉树结构)
问题描述:房屋现在排列成二叉树的结构,相连的两个房屋不能同时被偷窃。
text
输入: [3,2,3,null,3,null,1]
3
/ \
2 3
\ \
3 1
输出: 7
解释: 偷窃第1号、第3号和第5号房屋,金额=3+3+1=7。
小偷只能从根节点开始盗窃,对于某一层节点,有两者选择,可以选择都拿,或者都不拿,但是不能盗窃相邻层的节点。
解题思路
其实你类比一下就知道,这道题和打家劫舍1是十分相似的:
打家劫舍1 | 打家劫舍3(本题) |
---|---|
对于某个房屋,选择偷或不偷 | 对于某层节点,选择偷或不偷 |
偷第i个时,就不能考虑第i-1个 | 偷第i层时,就不能考虑第i-1层 |
不偷第i个时,沿用第i-1的状态 | 不偷第i层时,沿用第i-1的状态 |
我们按照以下步骤进行分析:
1.确定递归函数参数以及返回值
由于是普通的遍历过程,所以参数为当前节点即可
我们要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组。
js
const dfs = function(node){
return [NotDo,Do]
}
同时这里的返回数组其实就是dp数组
让我们分析dp的含义:dp[0]表示不偷当前节点时得到的最大数值,dp[1]表示偷当前节点时得到的最大数值
有人可能会说:长度为2的数组怎么标记树种每个节点的状态呢?
别忘了在递归中,系统栈会保存每一层递归的参数!
2.确定终止条件
在遍历的过程中,如果遇到空节点的话,不论是偷与不偷,最大的数值都会返回0
js
const dfs = function(node){
//遇到空节点
if(!node)
return [0,0];
//遇到非空节点
//...
return [NotDo,Do]
}
这里遇到空节点其实同时也是初始化的过程。
3.确定遍历顺序
对于下面这棵树,我们分析的顺序是从(3,1)->(2,3)->(3)
的往上的顺序,而且每个节点的状态取决于递推函数的返回值,所以我们确定使用后序遍历
js
3
/ \
2 3
\ \
3 1
通过递归左节点,得到左节点偷与不偷的金钱。
通过递归右节点,得到右节点偷与不偷的金钱。
代码如下:
js
var rob = function(root) {
if(!node)
return [0,0];
// 遍历左子树
let left = dfs(node.left);
// 遍历右子树
let right = dfs(node.right);
//单层处理(待完成)
...
return [DoNot,Do];
}
const res = dfs(root);
return Math.max(...res);
};
4.确定单层处理逻辑
这个时候对于每一层,已经拿到了左子树偷与不偷的最大结果let left = dfs(node.left);
,也拿到了右子树的偷与不偷的最大结果let right = dfs(node.right);
,那么我们就要开始动规五部曲中的确定递推公式了:
对于当前节点:
1.不偷当前节点:不偷当前节点的话,就可以沿用上一层左子树的状态和右子树的状态(因为左子树的状态和右子树的状态都代表着它们的最好结果)
js
// 不偷当前节点
let DoNot = Math.max(left[0],left[1]) + Math.max(right[0],right[1]);
这里和打家劫舍1同样的,我们只是引用左子树和右子树的状态,说明左子树和右子树偷和不偷都是可以的,于是我们取偷和不偷两种状态的最大值即可
2.偷当前节点 :偷当前节点,意味着我们就不能偷上一层节点了,所以我们得到上一层的左节点不偷时的最好状态 + 上一层的右节点不偷时的最好状态 + 这一层的数值为偷这一个节点时的最好状态
js
// 偷当前节点
let Do = node.val + left[0] + right[0];
5. 举例推导dp数组
以示例1为例,dp数组状态如下:(注意用后序遍历的方式推导)
树的结构
markdown
3
/ \
2 3
\ \
3 1
节点编号(方便描述):
-
根节点:3 (A)
-
左子树:2 (B) → 右子节点3 (D)
-
右子树:3 (C) → 右子节点1 (E)
-
Do[node]
:偷当前节点时,以该节点为根的子树能获得的最大金额。- 不能偷直接子节点(但可以偷孙子节点)。
-
NotDo[node]
:不偷当前节点时,以该节点为根的子树能获得的最大金额。- 可以自由选择偷或不偷子节点。
计算顺序(后序遍历)
(1) 节点 D (3)
- 是叶子节点,无子节点。
Do[D]
:偷 D →D.val = 3
NotDo[D]
:不偷 D →0
- 状态 :
(Do=3, NotDo=0)
(2) 节点 B (2)
-
左子节点:无
-
右子节点:D(状态
(Do=3, NotDo=0)
) -
Do[B]
:偷 B →B.val + NotDo[D] = 2 + 0 = 2
- 不能偷 D(直接子节点)。
-
NotDo[B]
:不偷 B →max(Do[D], NotDo[D]) = max(3, 0) = 3
- 可以自由选择偷或不偷 D,取最大值。
-
状态 :
(Do=2, NotDo=3)
(3) 节点 E (1)
- 是叶子节点,无子节点。
Do[E]
:偷 E →E.val = 1
NotDo[E]
:不偷 E →0
- 状态 :
(Do=1, NotDo=0)
(4) 节点 C (3)
-
左子节点:无
-
右子节点:E(状态
(Do=1, NotDo=0)
) -
Do[C]
:偷 C →C.val + NotDo[E] = 3 + 0 = 3
- 不能偷 E(直接子节点)。
-
NotDo[C]
:不偷 C →max(Do[E], NotDo[E]) = max(1, 0) = 1
- 可以自由选择偷或不偷 E,取最大值。
-
状态 :
(Do=3, NotDo=1)
(5) 根节点 A (3)
-
左子节点:B(状态
(Do=2, NotDo=3)
) -
右子节点:C(状态
(Do=3, NotDo=1)
) -
Do[A]
:偷 A →A.val + NotDo[B] + NotDo[C] = 3 + 3 + 1 = 7
- 不能偷 B 和 C(直接子节点),但可以偷它们的子节点(D 和 E)。
NotDo[B]=3
对应偷 D(因为max(Do[D], NotDo[D])=max(3,0)=3
)。NotDo[C]=1
对应偷 E(因为max(Do[E], NotDo[E])=max(1,0)=1
)。- 因此实际偷的节点:A、D、E → 3 + 3 + 1 = 7。
-
NotDo[A]
:不偷 A →max(Do[B], NotDo[B]) + max(Do[C], NotDo[C]) = max(2,3) + max(3,1) = 3 + 3 = 6
-
可以自由选择偷或不偷 B 和 C。
-
最优选择:
- 对 B:选
NotDo[B]=3
(偷 D)。 - 对 C:选
Do[C]=3
(偷 C,不偷 E)。 - 实际偷的节点:D、C → 3 + 3 = 6。
- 对 B:选
-
-
状态 :
(Do=7, NotDo=6)
最终结果
max(Do[A], NotDo[A]) = max(7, 6) = 7
最大金额为 7 ,对应的偷窃方案是 A、D、E。
节点 | Do (偷) | NotDo (不偷) | 说明 |
---|---|---|---|
D | 3 | 0 | 叶子节点 |
B | 2 | 3 | 偷 B 得 2;不偷 B 得 3(来自偷 D) |
E | 1 | 0 | 叶子节点 |
C | 3 | 1 | 偷 C 得 3;不偷 C 得 1(来自偷 E) |
A | 7 | 6 | 偷 A 得 7(A+D+E);不偷 A 得 6(D+C) |
完整代码:
js
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number}
*/
var rob = function(root) {
if(!node)
return [0,0];
// 遍历左子树
let left = dfs(node.left);
// 遍历右子树
let right = dfs(node.right);
// 不偷当前节点
let DoNot = Math.max(left[0],left[1]) + Math.max(right[0],right[1]);
// 偷当前节点
let Do = node.val + left[0] + right[0];
//
return [DoNot,Do];
}
const res = dfs(root);
return Math.max(...res);
};
总结
打家劫舍系列相对来说比较重要,作者也在这上面花了很长时间,包括创作这篇文章,一方面能够给自己的分析思路进行总结,另一方面也希望我总结出来的内容能够帮助到大家理解!
部分内容参照程序员卡尔的代码随想录
创作不易,如果感觉对你有帮助的话,可以点个赞呀!谢谢!