LeetCode 198. 打家劫舍

题目描述

给定一个代表每间房屋存放金额的非负整数数组 nums。如果同一晚偷窃两间相邻的房屋,就会触发警报。

在不触发警报的前提下,返回一晚能够偷到的最高金额。

例如:

复制代码
输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃第 0、2、4 间房,金额为 2 + 9 + 1 = 12。

原始思路:枚举选或不选

每到一间房,都有两种选择:

  • 偷当前房屋,下一次只能考虑下标 i + 2
  • 不偷当前房屋,下一次继续考虑下标 i + 1

因此,最开始使用 DFS 枚举所有选择,并用 s 记录当前路径已经偷到的金额。

伪代码:

复制代码
函数 rob(nums):
    ans <- 负无穷
    dfs(0, 0)
    返回 ans

函数 dfs(i, s):
    如果 i >= nums.length:
        ans <- max(ans, s)
        返回

    dfs(i + 2, s + nums[i])    // 偷当前房屋
    dfs(i + 1, s)              // 不偷当前房屋

对应 Java 代码:

复制代码
class Solution {
    int ans;

    public int rob(int[] nums) {
        ans = Integer.MIN_VALUE;
        dfs(0, nums, 0);
        return ans;
    }

    public void dfs(int i, int[] nums, int s) {
        if (i >= nums.length) {
            ans = Math.max(ans, s);
            return;
        }

        s += nums[i];
        dfs(i + 2, nums, s);
        s -= nums[i];
        dfs(i + 1, nums, s);
    }
}

这个思路能够得到正确结果,但递归会重复计算大量相同的子问题,时间复杂度为指数级,数据规模较大时会超时。

重复子问题

从不同的选择路径出发,可能到达同一个下标。例如到达下标 i 后,后面有哪些房屋可选,只与 i 有关,与之前经过了哪条路径无关。

因此,可以重新定义递归函数:

复制代码
dfs(i):从第 i 间房开始,最多还能偷到多少钱

在这个定义下,相同的 i 对应相同的答案。只要把第一次计算得到的 dfs(i) 缓存下来,之后再次遇到它时直接返回,就可以避免重复搜索。

状态转移

对于第 i 间房,仍然考虑两种选择。

偷当前房屋:

复制代码
nums[i] + dfs(i + 2)

不偷当前房屋:

复制代码
dfs(i + 1)

两者取最大值,得到递推式:

复制代码
dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))

i >= nums.length 时,后面已经没有房屋,最多还能偷到的金额为 0

复制代码
dfs(i) = 0,i >= nums.length

由于 dfs 直接返回当前状态的最优结果,不再需要累计金额 s 和全局变量 ans

手动模拟

nums = [2,7,9,3,1] 为例,各状态的结果如下:

复制代码
dfs(4) = max(1 + dfs(6), dfs(5)) = 1
dfs(3) = max(3 + dfs(5), dfs(4)) = 3
dfs(2) = max(9 + dfs(4), dfs(3)) = 10
dfs(1) = max(7 + dfs(3), dfs(2)) = 10
dfs(0) = max(2 + dfs(2), dfs(1)) = 12

最终答案是 dfs(0) = 12

记忆化搜索代码

伪代码:

复制代码
函数 rob(nums):
    创建长度为 nums.length 的 memo 数组
    将 memo 中的元素全部初始化为 -1
    返回 dfs(0)

函数 dfs(i):
    如果 i >= nums.length:
        返回 0
    如果 memo[i] != -1:
        返回 memo[i]

    偷 <- nums[i] + dfs(i + 2)
    不偷 <- dfs(i + 1)
    memo[i] <- max(偷, 不偷)
    返回 memo[i]

对应 Java 代码:

复制代码
class Solution {
    int[] memo;

    public int rob(int[] nums) {
        memo = new int[nums.length];
        for (int i = 0; i < memo.length; i++) {
            memo[i] = -1;
        }
        return dfs(0, nums);
    }

    public int dfs(int i, int[] nums) {
        if (i >= nums.length) {
            return 0;
        }
        if (memo[i] != -1) {
            return memo[i];
        }

        memo[i] = Math.max(
            nums[i] + dfs(i + 2, nums),
            dfs(i + 1, nums)
        );
        return memo[i];
    }
}

底向上动态规划

记忆化搜索按照递归需要计算状态,也可以反过来,从已经确定的边界状态开始,依次计算出 dp[i]

继续沿用前面的状态定义:

复制代码
dp[i]:从第 i 间房开始,最多能偷到多少钱

因为 dp[i] 依赖 dp[i + 1]dp[i + 2],所以需要从右向左遍历。为了让最后两间房也能直接使用同一个递推式,可以创建长度为 n + 2 的数组,令 dp[n]dp[n + 1] 默认为 0

伪代码:

复制代码
函数 rob(nums):
    n <- nums.length
    创建长度为 n + 2、初始值全为 0 的 dp 数组

    对 i 从 n - 1 递减到 0:
        偷 <- nums[i] + dp[i + 2]
        不偷 <- dp[i + 1]
        dp[i] <- max(偷, 不偷)

    返回 dp[0]

对应 Java 代码:

复制代码
class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n + 2];

        for (int i = n - 1; i >= 0; i--) {
            dp[i] = Math.max(nums[i] + dp[i + 2], dp[i + 1]);
        }
        return dp[0];
    }
}

这里的递推式与记忆化搜索完全相同,只是计算顺序从递归调用改成了从右向左填表,因此不再需要递归调用栈。

空间优化

计算 dp[i] 时只会用到右侧相邻的两个状态,不需要保留整个 dp 数组。可以使用两个变量分别表示 dp[i + 1]dp[i + 2]

伪代码:

复制代码
函数 rob(nums):
    nextOne <- 0    // 表示 dp[i + 1]
    nextTwo <- 0    // 表示 dp[i + 2]

    对 i 从 nums.length - 1 递减到 0:
        current <- max(nums[i] + nextTwo, nextOne)
        nextTwo <- nextOne
        nextOne <- current

    返回 nextOne

对应 Java 代码:

复制代码
class Solution {
    public int rob(int[] nums) {
        int nextOne = 0;
        int nextTwo = 0;

        for (int i = nums.length - 1; i >= 0; i--) {
            int current = Math.max(nums[i] + nextTwo, nextOne);
            nextTwo = nextOne;
            nextOne = current;
        }
        return nextOne;
    }
}

更新变量时要先计算 current,再整体向左移动状态:

复制代码
nextTwo <- 原来的 nextOne
nextOne <- current

循环结束后,nextOne 表示 dp[0]

易错点

1. 返回 memo[nums.length - 1]

memo[i] 表示从第 i 间房开始能偷到的最大金额,所以整个问题对应的是 dfs(0),而不是最后一个位置的状态。

直接返回 dfs(0, nums) 也能自然处理空数组:当数组长度为 0 时,第一次调用就满足越界条件并返回 0,不会访问 memo[0]

2. 使用 0 表示状态尚未计算

房屋金额可以为 0,所以某个状态的正确结果也可能是 0。如果使用下面的判断:

复制代码
if (memo[i] != 0) {
    return memo[i];
}

就无法区分"尚未计算"和"计算结果为 0"。这通常不会改变最终答案,但会让结果为 0 的状态被重复计算。

题目保证金额非负,因此可以把 memo 初始化为 -1,用它表示尚未计算。

3. 保留累计金额和全局答案

如果 dfs(i) 的含义是"从第 i 间房开始最多还能偷多少钱",它只需要返回未来能够获得的最大收益。上一层会把当前房屋的金额加到返回值上,因此不需要继续传递累计金额 s,也不需要用全局变量 ans 收集答案。

4. 把暴力递归复杂度写成 O(n^2)

暴力 DFS 在每个位置都可能产生"偷"和"不偷"两个分支,搜索树的规模是指数级,而不是平方级。加入记忆化后,每个下标对应的状态只会真正计算一次。

5. 底向上遍历方向写反

当前状态依赖 i + 1i + 2,必须先算出右侧状态,所以需要从右向左遍历。如果改用"前 i 间房的最大金额"作为状态,遍历方向和下标含义也要随之改变,不能混用两种定义。

复杂度分析

  • 记忆化搜索:时间复杂度为 O(n),空间复杂度为 O(n),空间来自 memo 数组和递归调用栈。
  • 底向上动态规划:时间复杂度为 O(n),空间复杂度为 O(n)
  • 空间优化后:时间复杂度为 O(n),空间复杂度为 O(1)

复盘

这道题原本"选或不选"的递归方向是正确的,超时的关键在于不同路径会反复计算同一个下标之后的最优结果。

解决这类问题时,可以先明确递归函数的含义,再依次确定四件事:

复制代码
状态:dfs(i) 表示什么
选择:当前状态有哪些选择
边界:什么时候停止递归
返回:整个问题对应哪个状态

本题可以记住下面的核心递推式:

复制代码
dfs(i) = max(nums[i] + dfs(i + 2), dfs(i + 1))

明确了 dfs(i) 的含义后,累计金额、全局答案、最终返回位置和缓存方式也会自然确定。再把相同的递推式改为从右向左计算,就能从记忆化搜索过渡到底向上动态规划,并利用只依赖两个相邻状态的特点把空间优化到 O(1)

相关推荐
吃着火锅x唱着歌1 小时前
LeetCode 648.单词替换
算法·leetcode·职场和发展
一只积极向上的小咸鱼2 小时前
分词器tokenizer
算法·llm
Elsa️7462 小时前
leetcode 14.最长公共前缀
算法·leetcode·职场和发展
不会代码的小猴2 小时前
6. Qt网络编程
开发语言·c++·笔记·qt·算法
Zguigo2 小时前
树的前序|中序|后序遍历【使用栈实现】
数据结构·算法
zhanghaha13142 小时前
HTML系列教程:4_什么是 HTML 元素(零基础超详细讲解)
前端·算法·html
TAN-90°-2 小时前
Deep Learning for Computer Vision——Training CNNs and CNN Architectures
人工智能·深度学习·神经网络·算法·机器学习·计算机视觉·cnn
0x3F(小茶)2 小时前
Tokenization(分词算法):一切大语言模型的地基
人工智能·算法·语言模型
Keven_112 小时前
算法札记:利用floyd判环算法优化SPFA判负环算法
算法·spfa判负环