一文全面解析打家劫舍问题
从基础的线性数组问题,到环形数组问题,再到树形结构问题,我们可以通过定义合适的状态、找出状态转移方程,并结合边界条件,逐步解决打家劫舍及其变体问题。在解决这类问题时,关键在于准确分析问题的特点,合理定义状态和状态转移方程。希望通过我在本文的详细解析,能够帮你深入理解打家劫舍问题及其解法,并且可以熟练运用动态规划的思想。
一、打家劫舍基础问题
1.1 题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组 nums
,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。(LeetCode 198)
1.2 解题思路
采用动态规划的方法解决该问题。定义状态 dp[i]
表示偷窃到第 i
个房屋时能获得的最高金额。对于第 i
个房屋,存在两种情况:
- 不偷第
i
个房屋:此时能获得的最高金额等于偷窃到第i - 1
个房屋时的金额,即dp[i] = dp[i - 1]
。 - 偷第
i
个房屋:因为不能偷相邻的第i - 1
个房屋,所以此时能获得的最高金额等于偷窃到第i - 2
个房屋时的金额加上第i
个房屋的金额,即dp[i] = dp[i - 2] + nums[i]
。
综合两种情况,dp[i]
取上述两种情况的较大值,即 dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i])
。初始状态为 dp[0] = nums[0]
(只有一间房屋时,直接偷该房屋),dp[1] = Math.max(nums[0], nums[1])
(有两间房屋时,选择金额较大的房屋偷窃) 。
1.3 Java代码实现
public class HouseRobber {
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) {
return nums[0];
}
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1];
}
}
1.4 复杂度分析
- 时间复杂度:需要遍历一次数组来填充
dp
数组,时间复杂度为 O ( n ) O(n) O(n),其中n
是数组nums
的长度。 - 空间复杂度:使用了一个长度为
n
的数组dp
来存储中间结果,空间复杂度为 O ( n ) O(n) O(n)。通过优化,也可以只使用两个变量来记录dp[i - 1]
和dp[i - 2]
的值,将空间复杂度降低到 O ( 1 ) O(1) O(1)。
二、打家劫舍II问题
2.1 题目描述
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。(LeetCode 213)
2.2 解题思路
由于房屋围成一圈,导致问题变得复杂。但可以通过分类讨论将其转化为基础的打家劫舍问题:
- 情况一:不偷第一个房屋,此时问题就相当于对从第二个房屋到最后一个房屋的数组进行基础的打家劫舍问题求解。
- 情况二:不偷最后一个房屋,此时问题就相当于对从第一个房屋到倒数第二个房屋的数组进行基础的打家劫舍问题求解。
分别计算两种情况下能获得的最高金额,取其中的较大值作为最终结果。
2.3 Java代码实现
public class HouseRobberII {
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) {
return nums[0];
}
// 情况一:不偷第一个房屋
int result1 = robRange(nums, 1, n - 1);
// 情况二:不偷最后一个房屋
int result2 = robRange(nums, 0, n - 2);
return Math.max(result1, result2);
}
private int robRange(int[] nums, int start, int end) {
int prev1 = 0;
int prev2 = 0;
for (int i = start; i <= end; i++) {
int temp = prev1;
prev1 = Math.max(prev1, prev2 + nums[i]);
prev2 = temp;
}
return prev1;
}
}
2.4 复杂度分析
- 时间复杂度:两次调用
robRange
方法,每次遍历数组的一部分,时间复杂度为 O ( n ) O(n) O(n),整体时间复杂度为 O ( n ) O(n) O(n)。 - 空间复杂度:在
robRange
方法中,只使用了几个临时变量,空间复杂度为 O ( 1 ) O(1) O(1),所以整体空间复杂度也为 O ( 1 ) O(1) O(1)。
三、打家劫舍III问题
3.1 题目描述
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为根。除了根之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。给定二叉树的根节点 root
,返回在不触动警报的情况下,小偷能够偷窃到的最高金额。(LeetCode 337)
3.2 解题思路
采用树形动态规划的方法。对于二叉树的每个节点,定义一个长度为 2 的数组 int[] res = new int[2]
来记录状态:
res[0]
表示不偷当前节点时,以当前节点为根的子树能获得的最高金额。res[1]
表示偷当前节点时,以当前节点为根的子树能获得的最高金额。
对于每个节点,计算 res[0]
和 res[1]
的值:
- 计算
res[0]
:不偷当前节点,那么可以偷它的左子树和右子树的任意节点,所以res[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1])
,其中left
和right
分别是左子树和右子树返回的状态数组。 - 计算
res[1]
:偷当前节点,那么不能偷它的左子树和右子树的根节点,所以res[1] = root.val + left[0] + right[0]
。
通过递归地计算每个节点的状态数组,最终返回 Math.max(rootRes[0], rootRes[1])
,其中 rootRes
是根节点的状态数组。
3.3 Java代码实现
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public class HouseRobberIII {
public int rob(TreeNode root) {
int[] res = robTree(root);
return Math.max(res[0], res[1]);
}
private int[] robTree(TreeNode root) {
if (root == null) {
return new int[2];
}
int[] left = robTree(root.left);
int[] right = robTree(root.right);
int[] res = new int[2];
// 不偷当前节点
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.4 复杂度分析
- 时间复杂度:每个节点都会被访问且仅访问一次,时间复杂度为
O
(
n
)
O(n)
O(n),其中
n
是二叉树的节点数。 - 空间复杂度:递归过程中使用的栈空间最大为二叉树的高度,在最坏情况下(二叉树为一条链),空间复杂度为 O ( n ) O(n) O(n);平均情况下,对于平衡二叉树,空间复杂度为 O ( l o g n ) O(log n) O(logn) 。
That’s all, thanks for reading!
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~