动态规划解决打家劫舍问题

124 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情

动态规划解决打家劫舍问题

1. 打家劫舍

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你 **不触动警报装置的情况下 **,一夜之内能够偷窃到的最高金额。

要求

示例 1:

输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:

输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

力扣链接:leetcode.cn/problems/ho…

思路

首先考虑最简单的情况。如果只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。如果只有两间房屋,则由于两间房屋相邻,不能同时偷窃,只能偷窃其中的一间房屋,因此选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额。

如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k(k>2)间房屋,有两个选项:

  1. 偷窃第 k 间房屋,那么就不能偷窃第 k−1 间房屋,偷窃总金额为前 k−2 间房屋的最高总金额与第 k 间房屋的金额之和。
  2. 不偷窃第 k 间房屋,偷窃总金额为前 k−1 间房屋的最高总金额。

在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 k 间房屋能偷窃到的最高总金额。

_dp_[_i_] 表示前 i 间房屋能偷窃到的最高总金额,那么就有如下的状态转移方程: dp[i] = max(dp[i − 2] + nums[i], dp[i − 1])

代码

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  const length = nums.length;
  if (length === 1) return nums[0];
  const dp = Array(length).fill(0);
  dp[0] = nums[0];
  dp[1] = Math.max(nums[0], nums[1]);
  for(let i = 2; i < length; i++) {
    dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
  }
  return dp[length - 1];
};

上述方法使用了数组存储结果。考虑到每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,因此可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额。

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  let [dp1, dp2, dp] = [0, 0, 0];
  for(let i = 0; i < nums.length; i++) {
    dp = Math.max(dp1 + nums[i], dp2);
    dp1 = dp2;
    dp2 = dp;
  }
  return dp;
};

2. 打家劫舍 II

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

要求

示例 1:

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

示例 2:

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 3:

输入:nums = [1,2,3]
输出:3

力扣链接:leetcode.cn/problems/ho…

思路

首先考虑最简单的情况。如果只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。如果只有两间房屋,则由于两间房屋相邻,不能同时偷窃,只能偷窃其中的一间房屋,因此选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额。

注意到当房屋数量不超过两间时,最多只能偷窃一间房屋,因此不需要考虑首尾相连的问题。如果房屋数量大于两间,就必须考虑首尾相连的问题,第一间房屋和最后一间房屋不能同时偷窃。

如何才能保证第一间房屋和最后一间房屋不同时偷窃呢?如果偷窃了第一间房屋,则不能偷窃最后一间房屋,因此偷窃房屋的范围是第一间房屋到最后第二间房屋;如果偷窃了最后一间房屋,则不能偷窃第一间房屋,因此偷窃房屋的范围是第二间房屋到最后一间房屋。

假设数组 nums 的长度为 n。如果不偷窃最后一间房屋,则偷窃房屋的下标范围是 [0, n - 2];如果不偷窃第一间房屋,则偷窃房屋的下标范围是[1,n − 1]。在确定偷窃房屋的下标范围之后,即可用上题的方法解决。对于两段下标范围分别计算可以偷窃到的最高总金额,其中的最大值即为在 n 间房屋中可以偷窃到的最高总金额。

假设偷窃房屋的下标范围是 [start, end],用dp[i] 表示在下标范围 [start, i] 内可以偷窃到的最高总金额,那么就有如下的状态转移方程: dp[i] = max(dp[i − 2] + nums[i], dp[i − 1]) 考虑到每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,因此可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额,将空间复杂度降到 O(1)。

代码

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  if (nums.length === 1) return nums[0];
  return Math.max(robRange(0, nums.length - 1), robRange(1, nums.length));
  function robRange(start, end) {
    let [dp1, dp2, dp] = [0, 0, 0];
    for(let i = start; i < end; i++) {
      dp = Math.max(dp1 + nums[i], dp2);
      dp1 = dp2;
      dp2 = dp;
    }
    return dp;
  }
};

3. 打家劫舍 III

题目描述

小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root

除了 root 之外,每栋房子有且只有一个 "父" 房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。

给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。

要求

示例 1: image.png

输入: root = [3,2,3,null,3,null,1]
输出: 7 
解释: 小偷一晚能够盗取的最高金额 3 + 3 + 1 = 7

示例 2: image.png

输入: root = [3,4,5,1,3,null,1]
输出: 9
解释: 小偷一晚能够盗取的最高金额 4 + 5 = 9

力扣链接:leetcode.cn/problems/ho…

思路

本题目本身就是动态规划的树形版本,通过此题解,可以了解一下树形问题在动态规划问题解法,我们通过两个方法不断递进解决问题。

  • 解法一通过递归实现,虽然解决了问题,但是复杂度太高
  • 解法二通过解决方法一中的重复子问题,实现了性能的百倍提升

解法一、暴力递归 - 最优子结构 我们使用爷爷、两个孩子、4 个孙子来说明问题。首先来定义这个问题的状态

  • 首先要明确相邻的节点不能偷,也就是爷爷选择偷,儿子就不能偷了,但是孙子可以偷
  • 二叉树只有左右两个孩子,一个爷爷最多 2 个儿子,4 个孙子

根据以上条件,我们可以得出单个节点的钱该怎么算 4 个孙子偷的钱 + 爷爷的钱 VS 两个儿子偷的钱 哪个组合钱多,就当做当前节点能偷的最大钱数。这就是动态规划里面的最优子结构 由于是二叉树,这里可以选择计算所有子节点 4 个孙子投的钱加上爷爷的钱如下: method1 = root.val + rob(root.left.left) + rob(root.left.right) + rob(root.right.left) + rob(root.right.right); 两个儿子偷的钱如下: method2 = rob(root.left) + rob(root.right); 挑选一个钱数多的方案则 result = Math.max(method1, method2); 将上述方案写成代码如下:

/**
 * 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 (!root) return 0;
  let money = root.val;
  if (root.left) {
    money += (rob(root.left.left) + rob(root.left.right));
  }
  if (root.right) {
    money += (rob(root.right.left) + rob(root.right.right));
  }
  return Math.max(money, rob(root.left) + rob(root.right));
};

解法二、记忆化 - 解决重复子问题 针对解法一种速度太慢的问题,经过分析其实现,我们发现爷爷在计算自己能偷多少钱的时候,同时计算了 4 个孙子能偷多少钱,也计算了 2 个儿子能偷多少钱。这样在儿子当爷爷时,就会产生重复计算一遍孙子节点。 于是乎我们发现了一个动态规划的关键优化点 重复子问题 我们这一步针对重复子问题进行优化,我们在做斐波那契数列时,使用的优化方案是记忆化,但是之前的问题都是使用数组解决的,把每次计算的结果都存起来,下次如果再来计算,就从缓存中取,不再计算了,这样就保证每个数字只计算一次。 由于二叉树不适合拿数组当缓存,我们这次使用哈希表来存储结果,TreeNode 当做 key,能偷的钱当做 value 解法一加上记忆化优化后代码如下:

/**
 * 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) {
  const memo = new Map();
  return robInternal(root);

  function robInternal (root) {
    if (!root) return 0;
    if (memo.has(root)) return memo.get(root);
    let money = root.val;

    if (root.left) {
      money += (robInternal(root.left.left) + robInternal(root.left.right));
    }
    if (root.right) {
      money += (robInternal(root.right.left) + robInternal(root.right.right));
    }
    const result = Math.max(money, robInternal(root.left) + robInternal(root.right));
    memo.set(root, result);
    return result;
  }
};