LeetCode算法日记 - Day 74: 按摩师、打家劫舍II
目录
1. 按摩师
1.1 题目解析
1.2 解法
1.3 代码实现
2. 打家劫舍II
2.1 题目解析
2.2 解法
2.3 代码实现
1. 按摩师
https://leetcode.cn/problems/the-masseuse-lcci/description/
一个有名的按摩师会收到源源不断的预约请求,每个预约都可以选择接或不接。在每次预约服务之间要有休息时间,因此她不能接受相邻的预约。给定一个预约请求序列,替按摩师找到最优的预约集合(总预约时间最长),返回总的分钟数。
注意:本题相对原题稍作改动
示例 1:
输入: [1,2,3,1] 输出: 4 解释: 选择 1 号预约和 3 号预约,总时长 = 1 + 3 = 4。
示例 2:
输入: [2,7,9,3,1] 输出: 12 解释: 选择 1 号预约、 3 号预约和 5 号预约,总时长 = 2 + 9 + 1 = 12。
示例 3:
输入: [2,1,4,5,3,1,1,3] 输出: 12 解释: 选择 1 号预约、 3 号预约、 5 号预约和 8 号预约,总时长 = 2 + 4 + 3 + 3 = 12。
1.1 题目解析
题目本质
这是一个多状态 dp 问题,核心是在数组中选择若干不相邻的元素,使其和最大。
常规解法
最直观的想法是枚举所有可能的选择方案,对于每个预约,尝试"选"和"不选"两种情况,然后比较所有合法方案(不包含相邻预约)的总时长,取最大值。
问题分析
暴力枚举需要遍历所有 2^n 种子集组合,再判断是否有相邻元素,时间复杂度达到 O(2^n),当预约数量稍大时就会超时。问题的关键在于存在大量重复计算——对于位置 i 的决策,后续子问题会被重复求解多次。
思路转折
要想高效求解 → 必须避免重复计算 → 使用动态规划记录状态。观察发现,对于第 i 个预约,我们只需要知道"前 i-1 个预约中,最后一个是否被选择"这个状态,就能推导出当前的最优解。因此定义两个状态:
-
f[i]:前 i 个预约中,选择第 i 个预约的最大时长
-
g[i]:前 i 个预约中,不选第 i 个预约的最大时长
这样可以通过前一个位置的状态递推得到当前状态,时间复杂度降至 O(n)。
1.2 解法
算法思想: 动态规划,维护两个状态数组。状态转移方程为:
-
f[i] = g[i-1] + nums[i](选第 i 个,则第 i-1 个必不选)
-
g[i] = max(f[i-1], g[i-1])(不选第 i 个,取前一个选或不选的最大值)
最终答案为 max(f[n-1], g[n-1])。
i)初始化两个数组 f 和 g,长度为预约数量
ii)处理边界:当数组为空时返回 0,否则设置 f[0] = nums[0](选第一个),g[0] = 0(不选第一个)
iii)从位置 1 开始遍历数组,依次计算每个位置的 f[i] 和 g[i]
iv)返回最后一个位置选或不选的最大值
易错点
-
边界条件处理:需要单独判断数组为空的情况,以及初始化 f[0] 和 g[0]
-
状态转移理解:f[i] 表示"必选第 i 个",所以只能从 g[i-1] 转移;而 g[i] 表示"不选第 i 个",可以从 f[i-1] 和 g[i-1] 中取最大值
-
最终结果:需要比较 f[m-1] 和 g[m-1] 取最大值,而不是只返回其中一个
1.3 代码实现
class Solution {int[] f; // f[i]: 选择第i个预约的最大时长int[] g; // g[i]: 不选第i个预约的最大时长public int massage(int[] nums) {int m = nums.length;if(m == 0) return 0;f = new int[m];g = new int[m];// 初始化f[0] = nums[0];g[0] = 0;// 状态转移for(int i = 1; i < m; i++){f[i] = g[i-1] + nums[i]; // 选第i个,加上前一个不选的最优值g[i] = Math.max(f[i-1], g[i-1]); // 不选第i个,取前一个的最优值}return Math.max(f[m-1], g[m-1]);}
}
复杂度分析
-
时间复杂度: O(n),只需遍历一次数组,每个位置的状态转移为 O(1)
-
空间复杂度: O(n),需要两个长度为 n 的数组存储状态。可优化至 O(1),因为每次只需要前一个位置的状态,用变量滚动即可
2. 打家劫舍II
https://leetcode.cn/problems/house-robber-ii/description/
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 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
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 1000
2.1 题目解析
题目本质
本质上是"带环形约束的序列最优选择"问题,换句话说就是"一圈房子围成环,不能偷挨着的两家,怎么偷能拿最多钱"核心难点在于首尾相邻的特殊处理。
常规解法
最直观的想法是用递归/回溯:枚举所有不相邻的房屋组合,计算每种组合的总金额,取最大值。但需要额外处理首尾不能同时选的约束。
问题分析
纯递归会超时。对于 100 个房屋,需要枚举的组合数接近 O(2^100),且存在大量重复计算。关键问题是环形结构:普通的线性 DP 无法直接应用,因为第一间和最后一间相邻,选了第一间就不能选最后一间。
思路转折
要想高效 → 必须用动态规划 → 但要破环为链。关键观察:首尾相邻等价于"第一间和最后一间不能同时选"。可以分两种情况:① 偷第一间(不能偷最后一间),② 不偷第一间(可以偷最后一间)。每种情况都变成了线性 DP(打家劫舍 I),分别求最大值,再取两者的较大值。这样就把环形问题转化为两个线性问题。
2.2 解法
算法思想
破环为链,分两种情况分别用线性 DP 求解,取最大值。
情况1:偷第一间房 → 最后一间不能偷 → DP 范围:[0, n-2]
情况2:不偷第一间房 → 最后一间可以偷 → DP 范围:[1, n-1]
线性 DP 状态定义:
-
f[i]:偷第 i 间房的最大金额
-
g[i]:不偷第 i 间房的最大金额
递推公式:
f[i] = g[i-1] + nums[i] // 偷i,不能偷i-1g[i] = max(f[i-1], g[i-1]) // 不偷i,前面随便选
i)
- 边界处理:n=0 返回 0,n=1 返回 nums[0],n=2 返回 max(nums[0], nums[1])
ii)调用线性 DP 求解两种情况:
-
返回两种情况的较大值
-
情况2:rob1(nums, 1, n-1) 处理 [1, n-1] 区间
-
情况1:rob1(nums, 0, n-2) 处理 [0, n-2] 区间
iii)返回两种情况的较大值
iv)rob1 方法实现:
- 初始化起点:f[left] = nums[left], g[left] = 0
- 从 left+1 遍历到 right,递推更新 f 和 g
- 返回 max(f[right], g[right])
易错点
-
边界判断不全:n=1 和 n=2 需要特殊处理。当 n=1 时只有一间房,直接偷;当 n=2 时两间相邻,只能偷其中较大的
-
区间范围错误:情况1 的范围是 [0, n-2](包含第一间),情况2 的范围是 [1, n-1](包含最后一间)。容易写反或遗漏边界
-
初始化混乱:rob1 方法中 f[left] = nums[left] 是初始化起点,从 left+1 开始循环。不要从 left 开始循环导致重复初始化
-
返回值选择错误:最终返回 max(f[right], g[right]),因为最后一间可能偷也可能不偷,要取最优的
2.3 代码实现
static class Solution {int n;public int rob(int[] nums) {n = nums.length;if (n == 0) return 0;else if (n == 1) return nums[0];else if (n == 2) return nums[0] > nums[1] ? nums[0] : nums[1];// 情况1:考虑第一间,不考虑最后一间,范围 [0, n-2]// 情况2:不考虑第一间,考虑最后一间,范围 [1, n-1]return Math.max(rob1(nums, 0, n - 2), rob1(nums, 1, n - 1));}// 线性打家劫舍(处理 [left, right] 范围)public int rob1(int[] nums, int left, int right) {int[] f = new int[n]; // 偷第 i 间房的最大金额int[] g = new int[n]; // 不偷第 i 间房的最大金额// 初始化起点f[left] = nums[left];g[left] = 0;// 从 left+1 遍历到 rightfor (int i = left + 1; i <= right; i++) {f[i] = g[i - 1] + nums[i]; // 偷i = 不偷i-1的最优 + nums[i]g[i] = Math.max(f[i - 1], g[i - 1]); // 不偷i = 前面的最优}// 返回最后位置偷或不偷的最大值return g[right] > f[right] ? g[right] : f[right];}
}
复杂度分析
-
时间复杂度:O(n),两次线性遍历,每次约 n 个元素
-
空间复杂度:O(n),使用了两组长度为 n 的数组(f, g 和 f1, g1 在不同调用中复用)