数组 | 动态规划
var rob = function (nums) {
n = nums.length;
function dfs(i) {
if (i < 0) {
return 0;
}
let res = Math.max(dfs(i - 1), dfs(i - 2) + nums[i]);
return res;
}
return dfs(n - 1);
};
我们来详细解析这个“打家劫舍”(House Robber)问题的解决方案。这个问题是动态规划中一个经典的例子,涉及到在一维数组中选择不相邻的元素以获得最大和。我们将逐步分析代码,理解其中的逻辑和动态规划的思想。
问题描述
题目:给定一个非负整数数组 nums
,代表每间房屋内存放的现金。小偷不能同时偷窃相邻的房屋。你的目标是计算小偷在不触发报警的情况下,能够偷窃到的最高金额。
示例:
- 输入:
nums = [2, 7, 9, 3, 1]
- 输出:
12
(偷窃房屋 1 和 3 的现金,将得到 2 + 9 + 1 = 12)
动态规划思路
在解决这个问题时,我们可以使用动态规划的方式来优化我们的选择。基本思路如下:
-
状态定义:假设
dp[i]
表示偷窃到第i
间房屋时能够获得的最大金额。 -
状态转移方程:
- 如果小偷选择偷窃第
i
间房屋,那么他不能偷窃第i-1
间房屋,因此他能获得的金额为nums[i] + dp[i-2]
。 - 如果小偷选择不偷窃第
i
间房屋,那么他能获得的金额就是dp[i-1]
。 - 所以,转移方程为:
[
dp[i] = \max(dp[i-1], nums[i] + dp[i-2])
]
- 如果小偷选择偷窃第
-
边界条件:
dp[0] = nums[0]
:如果只有一间房屋,小偷只能偷窃这一间。dp[1] = \max(nums[0], nums[1])
:如果有两间房屋,小偷会选择偷窃金额更高的那一间。
-
最终结果:
dp[n-1]
,即为偷窃到最后一间房屋时的最大金额。
代码解析
接下来,我们逐行分析你提供的代码:
var rob = function (nums) {
n = nums.length;
function dfs(i) {
if (i < 0) {
return 0;
}
let res = Math.max(dfs(i - 1), dfs(i - 2) + nums[i]);
return res;
}
return dfs(n - 1);
};
1. 函数定义
rob
函数接收一个数组nums
作为输入,表示每间房屋内的现金。
2. 变量 n
n = nums.length;
n
代表房屋的数量。
3. 递归函数 dfs
function dfs(i) {
if (i < 0) {
return 0;
}
let res = Math.max(dfs(i - 1), dfs(i - 2) + nums[i]);
return res;
}
- 参数
i
:当前考虑的房屋索引。 - 递归终止条件:当
i < 0
时,返回 0,表示没有房屋可偷。 - 状态转移:
dfs(i - 1)
:不偷窃第i
间房屋的情况下的最大金额。dfs(i - 2) + nums[i]
:偷窃第i
间房屋的情况下的最大金额。
- 返回值:返回这两种选择的最大值。
4. 开始递归
return dfs(n - 1);
- 从最后一间房屋开始递归计算最大金额。
代码的不足之处
尽管上面的代码实现了基本的逻辑,但它的效率较低,因为它使用了递归且没有缓存中间结果,导致许多子问题被重复计算。这样会导致时间复杂度为 O(2^n),在房屋数量较多时,会非常慢。
动态规划优化
为了提高效率,我们可以用动态规划的方法实现这个问题,避免重复计算。下面是使用动态规划的改进代码:
var rob = function(nums) {
const n = nums.length;
if (n === 0) return 0;
if (n === 1) return nums[0];
const dp = new Array(n);
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (let i = 2; i < n; i++) {
dp[i] = Math.max(dp[i - 1], nums[i] + dp[i - 2]);
}
return dp[n - 1];
};
代码解析
-
边界条件:
- 如果没有房屋 (
n === 0
),返回 0。 - 如果只有一间房屋 (
n === 1
),返回这一间的现金。
- 如果没有房屋 (
-
动态规划数组
dp
:dp[0]
初始化为nums[0]
。dp[1]
初始化为Math.max(nums[0], nums[1])
。
-
状态转移:
- 从第三间房屋(索引 2)开始,遍历每一间房屋,更新
dp[i]
为当前能够获得的最大金额。
- 从第三间房屋(索引 2)开始,遍历每一间房屋,更新
-
返回结果:
- 返回
dp[n - 1]
,即为偷窃到最后一间房屋的最大金额。
- 返回
时间与空间复杂度分析
- 时间复杂度:O(n),因为我们只遍历了一次房屋列表。
- 空间复杂度:O(n),用于存储动态规划数组。如果我们只关心前两个状态,可以进一步优化为 O(1) 的空间复杂度。
进一步优化(空间复杂度 O(1))
由于我们只需要前两个状态来计算当前状态,我们可以使用两个变量代替数组来保存状态。以下是进一步优化后的代码:
var rob = function(nums) {
const n = nums.length;
if (n === 0) return 0;
if (n === 1) return nums[0];
let prev1 = nums[0]; // dp[i-1]
let prev2 = Math.max(nums[0], nums[1]); // dp[i]
for (let i = 2; i < n; i++) {
let current = Math.max(prev2, nums[i] + prev1);
prev1 = prev2;
prev2 = current;
}
return prev2;
};
在这个优化版本中,我们仅使用了两个变量 prev1
和 prev2
来存储前两个状态,从而将空间复杂度降低到 O(1)。
问题概述
题目:给定一个非负整数数组 nums
,表示每间房屋内的现金。小偷不能同时偷窃相邻的房屋。你的目标是计算小偷在不触发报警的情况下,能够偷窃到的最高金额。
示例:
- 输入:
nums = [2, 7, 9, 3, 1]
- 输出:
12
(偷窃房屋 1 和 3 的现金,将得到 2 + 9 + 1 = 12)
代码解析
下面是代码的逐行解析:
var rob = function(nums) {
const n = nums.length;
const memo = new Array(n).fill(-1); // -1 表示没有计算过
1. 初始化
n
:房屋的数量。memo
:一个数组,用于存储已经计算过的结果,初始值为-1
,表示该索引还没有计算过。
2. 递归函数 dfs
function dfs(i) {
if (i < 0) { // 递归边界(没有房子)
return 0;
}
if (memo[i] !== -1) { // 之前计算过
return memo[i];
}
const res = Math.max(dfs(i - 1), dfs(i - 2) + nums[i]);
memo[i] = res; // 记忆化:保存计算结果
return res;
}
-
函数参数
i
:表示当前考虑的房屋索引。 -
递归边界:
- 当
i < 0
时,表示没有房屋可偷窃,返回 0。
- 当
-
记忆化检查:
- 如果
memo[i]
不等于-1
,说明之前已经计算过从nums[0]
到nums[i]
的最大金额,直接返回memo[i]
。
- 如果
-
状态转移:
- 计算偷窃的最大金额:
dfs(i - 1)
:不偷窃第i
间房屋的情况下的最大金额。dfs(i - 2) + nums[i]
:偷窃第i
间房屋的情况下的最大金额。
- 选择这两种情况中的最大值,并将结果存储到
memo[i]
中。
- 计算偷窃的最大金额:
-
返回值:返回当前计算的结果
res
。
3. 开始递归
return dfs(n - 1); // 从最后一个房子开始思考
};
- 从最后一间房屋(索引为
n-1
)开始递归计算能够偷窃的最大金额。
动态规划的思路
这段代码实际上实现了动态规划的递归版本,利用 记忆化(Memoization)来存储中间结果,避免重复计算。这样可以将时间复杂度从 O(2^n) 降低到 O(n),因为每个子问题只会被计算一次。
时间与空间复杂度分析
- 时间复杂度:O(n),因为我们只计算了每个房屋的最大可偷金额一次。
- 空间复杂度:O(n),用于存储
memo
数组以及递归调用栈的空间。
进一步优化(空间复杂度 O(1))
虽然当前的代码使用了记忆化来提高效率,但我们仍然可以进一步优化空间复杂度。我们只需要保存前两个状态,而不需要一个完整的数组。以下是优化后的代码:
var rob = function(nums) {
const n = nums.length;
if (n === 0) return 0;
if (n === 1) return nums[0];
let prev1 = 0; // dp[i-1]
let prev2 = 0; // dp[i-2]
for (let i = 0; i < n; i++) {
let current = Math.max(prev2 + nums[i], prev1);
prev2 = prev1;
prev1 = current;
}
return prev1; // 返回最后一间房屋的最大金额
};
优化后的代码解析
-
边界条件:
- 如果没有房屋 (
n === 0
),返回 0。 - 如果只有一间房屋 (
n === 1
),返回该房屋的现金。
- 如果没有房屋 (
-
状态变量:
prev1
表示偷窃到i-1
间房屋时的最大金额。prev2
表示偷窃到i-2
间房屋时的最大金额。
-
状态转移:
- 遍历每间房屋,计算当前房屋的最大可偷金额。
- 更新
prev1
和prev2
。
-
返回结果:
- 返回
prev1
,即为偷窃到最后一间房屋时的最大金额。
- 返回
在你提供的代码片段中,我们使用动态规划来解决“打家劫舍”问题。你提到的疑问主要集中在更新动态规划数组 f
的下标时,为什么我们只将 f
的下标加 2
,而 nums
的下标不需要加 2
。让我们来详细分析这个问题。
代码分析
首先,我们来看一下代码的结构:
var rob = function(nums) {
const n = nums.length;
const f = new Array(n + 2).fill(0); // 创建长度为 n + 2 的数组 f,并初始化为 0
for (let i = 0; i < n; i++) {
f[i + 2] = Math.max(f[i + 1], f[i] + nums[i]); // 更新 f 数组
}
return f[n + 1]; // 返回结果
};
1. 动态规划数组 f
的含义
f[i]
表示偷窃到第i
间房屋时能够获得的最大金额。- 为了处理边界情况,我们将
f
数组的长度设置为n + 2
,这样可以避免在计算时出现下标越界的问题。
2. 为什么 f
的下标加 2
在动态规划的转移方程中,我们需要考虑当前房屋 i
的值以及之前的房屋的值。具体来说,f[i + 2]
代表的是在偷窃到房屋 i
后的状态。我们需要比较两种情况:
- 不偷窃第
i
间房屋:此时的最大金额为f[i + 1]
。 - 偷窃第
i
间房屋:此时的最大金额为f[i] + nums[i]
。
3. nums
的下标为什么不需要加 2
- 在循环中,
i
的范围是从0
到n - 1
,这正好对应nums
数组的有效下标。 - 在更新
f[i + 2]
时,我们是在f
数组中填充结果,而nums[i]
仍然是对原始数组nums
的直接访问。 - 具体来说:
- 当
i = 0
时,f[2]
计算的是Math.max(f[1], f[0] + nums[0])
,这里的nums[0]
是直接使用的。 - 当
i = 1
时,f[3]
计算的是Math.max(f[2], f[1] + nums[1])
,这里的nums[1]
也是直接使用的。
- 当
因此,我们并不需要对 nums
的下标进行加 2
的操作,因为我们在循环中是按照原始的下标访问 nums
的。
4. 处理边界情况
- 由于我们在
f
数组的长度上加了2
,我们可以安全地访问f[n + 1]
而不会越界。 - 这样做的好处是,我们可以在实现中避免复杂的边界条件处理,使代码更加简洁和易懂。
问题概述
题目:给定一个非负整数数组 nums
,表示每间房屋内的现金。小偷不能同时偷窃相邻的房屋。你的目标是计算小偷在不触发报警的情况下,能够偷窃到的最高金额。
示例:
- 输入:
nums = [2, 7, 9, 3, 1]
- 输出:
12
(偷窃房屋 1 和 3 的现金,将得到 2 + 9 + 1 = 12)
代码解析
下面是代码的逐行解析:
var rob = function (nums) {
let n = nums.length;
let f0 = 0,
f1 = 0;
1. 初始化
n
:房屋的数量。f0
:表示偷窃到第i-2
间房屋时的最大金额(即不偷窃第i-1
间房屋的情况下的最大金额)。f1
:表示偷窃到第i-1
间房屋时的最大金额。
在初始状态下,f0
和 f1
都被设置为 0
,表示在没有偷窃任何房屋的情况下,最大金额为 0
。
2. 遍历房屋
for (let i = 0; i < n; i++) {
let newF = Math.max(f1, f0 + nums[i]);
- 使用一个循环遍历所有的房屋。
newF
计算当前房屋的最大可偷金额:f1
:不偷窃当前房屋i
的情况下的最大金额。f0 + nums[i]
:偷窃当前房屋i
的情况下的最大金额。这里的f0
是指在偷窃当前房屋之前的最大金额。
3. 更新状态
f0 = f1;
f1 = newF;
}
- 更新
f0
和f1
:f0
更新为之前的f1
,表示在下一个循环中,f0
成为新的f1
。f1
更新为newF
,即当前房屋的最大可偷金额。
4. 返回结果
return f1;
};
- 最终返回
f1
,即为偷窃到最后一间房屋时的最大金额。
动态规划的思路
在这个解决方案中,我们使用动态规划来优化问题的求解。具体来说:
-
状态定义:
f0
和f1
分别表示在不偷窃当前房屋和偷窃当前房屋时的最大金额。
-
状态转移方程:
- 在遍历每个房屋时,我们需要决定是偷窃当前房屋还是不偷窃。通过
Math.max(f1, f0 + nums[i])
来选择最大金额。
- 在遍历每个房屋时,我们需要决定是偷窃当前房屋还是不偷窃。通过
-
空间优化:
- 通过只使用两个变量
f0
和f1
,我们将空间复杂度从 O(n) 降低到 O(1),因为我们只关心前两个状态的值。
- 通过只使用两个变量
时间与空间复杂度分析
- 时间复杂度:O(n),因为我们遍历了一次房屋列表。
- 空间复杂度:O(1),只使用了常数个额外的变量。
进一步理解
通过这个代码,我们可以更深入地理解以下几个重要的算法概念:
-
动态规划的优化:在很多动态规划问题中,往往只需要前几个状态的信息。通过适当的状态定义和优化,可以显著减少空间复杂度。
-
状态转移的选择:在动态规划中,如何选择状态转移的方式是关键。这里通过比较偷与不偷的情况来决定当前的最优解。
-
递推关系的理解:理解动态规划中的递推关系是解决问题的核心。通过将问题拆解为子问题,我们可以逐步构建出最终的解。