点击关注上方“五分钟学算法”,

设为“置顶或星标”,第一时间送达干货。

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤

转自面向大象编程


本期例题:LeetCode 198. House Robber 打家劫舍(Easy)

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

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

示例:

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

House Robber 问题(翻译为小偷问题、打家劫舍问题)是一道非常经典的动态规划入门题目。如果你对于动态规划还不是很了解,或者没怎么做过动态规划的题目的话,那么我非常建议你用 House Robber 这道题来入门。

动态规划可能被很多同学奉为最难理解的算法题类型,因为它的解法很灵活,变化很多。但是动态规划又经常作为笔试题的压轴,我们不得不去面对它。

其实,动态规划是一类很讲究「触类旁通」的题型。很多动态规划的解法需要你做过某一类型的例题,再做类似的题目的时候就可以想起来相应的思路。如果你做过不少题目,但是拿到新的题目依然没有思路,那说明你做过的题目不够典型。例如很多文章拿来大讲特讲的换硬币问题,实际上并不太适合用来理解动态规划的思想。

这正是我写《LeetCode 例题精讲》系列文章的意义所在。我的文章会做到两点,一是选择最有代表性的例题,二是通过该例题讲解一类问题的解题套路。从本期开始的几篇文章我会讲解动态规划问题,每篇文章用一道最典型的例题,带你学习动态规划的解题套路。

本文选择一个非常典型的动态规划问题:打家劫舍,在一步步求解这道题的过程中,讲解动态规划题目的四个基本步骤。

动态规划的解题四步骤

动态规划的的四个解题步骤是:

  • 定义子问题
  • 写出子问题的递推关系
  • 确定 DP 数组的计算顺序
  • 空间优化(可选)

不瞒你说,在 LeetCode 上我做过的几十道动态规划题目,都是用这个解题四步骤做出来的。这个解题步骤适用于任何一道动态规划题目,能够让你很快理清解题的各个要点。

步骤一:定义子问题

稍微接触过一点动态规划的朋友都知道动态规划有一个「子问题」的定义。什么是子问题?子问题是和原问题相似,但规模较小的问题。例如这道打家劫舍问题,原问题是「从全部房子中能偷到的最大金额」,将问题的规模缩小,子问题就是「从 个房子中能偷到的最大金额」,用 表示。

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤
打家劫舍问题的子问题定义

可以看到,子问题是参数化的,我们定义的子问题中有参数 。假设一共有 个房子的话,就一共有 个子问题。动态规划实际上就是通过求这一堆子问题的解,来求出原问题的解。这要求子问题需要具备两个性质:

  • 原问题要能由子问题表示。例如这道题中, 时实际上就是原问题。否则,解了半天子问题还是解不出原问题,那子问题岂不是白解了。
  • 一个子问题的解要能通过其他子问题的解求出。例如这道题中, 可以由 求出,具体原理后面会解释。这个性质就是教科书中所说的「最优子结构」。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。

打家劫舍问题由于比较简单,定义子问题实际上是很直观的。一些比较难的动态规划题目可能需要一些定义子问题的技巧。

步骤二:写出子问题的递推关系

这一步是求解动态规划问题最关键的一步。然而,这一步也是最无法在代码中体现出来的一步。在做题的时候,最好把这一步的思路用注释的形式写下来。做动态规划题目不要求快,而要确保无误。否则,写代码五分钟,找 bug 半小时,岂不美哉?

我们来分析一下这道题的递推关系:

假设一共有 个房子,每个房子的金额分别是 ,子问题 表示偷前 个房子(即 )中能偷到的最大金额。那么,偷 个房子有两种偷法:

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤
子问题的递推关系

个房子中最后一个房子是 。如果不偷这个房子,那么问题就变成在前 个房子中偷到最大的金额,也就是子问题 。如果偷这个房子,那么前一个房子 显然不能偷,其他房子不受影响。那么问题就变成在前 个房子中偷到的最大的金额。两种情况中,选择金额较大的一种结果。

另外,我们在写递推关系的时候,还要注意递推关系的 base case。这样才能构成完整的递推关系,后面写代码也不容易在边界条件上出错。在这道题中,是 时的子问题:

  • 时,没有房子,所以
  • 时,只有一个房子,偷这个房子即可,所以

步骤三:确定 DP 数组的计算顺序

在确定了子问题的递推关系之后,下一步就是依次计算出这些子问题了。在很多教程中都会写,动态规划有两种计算顺序,一种是自顶向下的、使用备忘录的递归方法,一种是自底向上的、使用 DP 数组的循环方法。不过在普通的动态规划题目中,99% 的情况我们用循环方法就很好解决,所以我们最好在一开始就坚持自底向上方法,使用 DP 数组,这样才有助于领悟动态规划的真正精髓。

DP 数组也可以叫「子问题数组」,因为 DP 数组中的每一个元素都对应一个子问题。如下图所示,dp[k] 对应子问题 ,即偷前 间房子的最大金额。

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤
DP 数组与子问题的对应关系

那么,只要搞清楚了子问题的计算顺序,就可以确定 DP 数组的计算顺序。对于打家劫舍问题,我们分析子问题的依赖关系,发现每个 依赖 。也就是说,dp[k] 依赖 dp[k-1]dp[k-2],如下图所示。

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤
DP 数组的依赖顺序

那么,既然 DP 数组中的依赖关系都是向右指的,DP 数组的计算顺序就是从左向右。这样我们可以保证,计算一个子问题的时候,它所依赖的那些子问题已经计算出来了。

确定了 DP 数组的计算顺序之后,我们就可以写出题解代码了:

public int rob(int[] nums) {
    if (nums.length == 0) {
        return 0;
    }
    // 子问题:
    // f(k) = 偷 [0..k) 房间中的最大金额

    // f(0) = 0
    // f(1) = nums[0]
    // f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) }

    int N = nums.length;
    int[] dp = new int[N+1];
    dp[0] = 0;
    dp[1] = nums[0];
    for (int k = 2; k <= N; k++) {
        // 套用子问题的递推关系
        dp[k] = Math.max(dp[k-1], nums[k-1] + dp[k-2]);
    }
    return dp[N];
}

注意在上面的代码中,有一大段关于子问题定义、递推关系的公式。在面试的时候,我们一般需要先把子问题的定义、递推关系给面试官解释清楚,同时顺手把这些内容用注释的形式写下来。这样一来让自己写代码不容易出错,二来也能让面试官更好地理解自己的思路。

步骤四:空间优化

空间优化是动态规划问题的进阶内容了。对于初学者来说,可以不掌握这部分内容,写出来的代码无非是空间复杂度高一些。不过,打家劫舍问题同样是空间优化的一个很好的例子,所以你不妨了解一下。

让我们回顾一下在编程入门阶段学习的斐波那契数列的求解方法。斐波那契数列的递推关系是 。这是不是和打家劫舍问题一模一样?计算斐波那契数列的时候,我们只需要用到三个变量,并不需要什么 DP 数组:

// 计算斐波那契数列
// f(1) = f(2) = 1, f(n) = f(n-1) + f(n-2)
int fib(int n) {
    if (n <= 2) {
        return 1;
    }
    int a = 1;
    int b = 1;
    for (int i = 2; i < n; i++) {
        int c = a + b;
        a = b;
        b = c;
    }
    return b;
}

请记住这段代码,这段我们很早就会的代码里,其实蕴含了打家劫舍类动态规划问题的空间优化技巧。

空间优化的基本原理是,很多时候我们并不需要始终持有全部的 DP 数组。对于打家劫舍问题,我们发现,最后一步计算 的时候,实际上只用到了 的结果。 之前的子问题,实际上早就已经用不到了。

那么,我们可以只用两个变量保存两个子问题的结果,就可以依次计算出所有的子问题。这样一来,空间复杂度也从 降到了 。下面的动图比较了空间优化前和优化后的对比关系:

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤
空间优化前后对比(动图)

优化后的代码如下所示:

public int rob(int[] nums) {
    int prev = 0;
    int curr = 0;

    // 每次循环,计算「偷到当前房子为止的最大金额」
    for (int i : nums) {
        // 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2]
        // dp[k] = max{ dp[k-1], dp[k-2] + i }
        int temp = Math.max(curr, prev + i);
        prev = curr;
        curr = temp;
        // 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1]
    }

    return curr;
}

可以看到,我们只用到了三个变量:prevcurrtemp。在每一轮循环中,prev 都表示 dp[k-2]curr 都表示 dp[k-1],而 temp 负责计算出 dp[k] 的值,再往前循环复制。这三个变量的操作方式,和斐波那契数列中的三个变量一模一样。

如果你是 Python 选手的话,还可以利用多重赋值的语法,去掉 temp 变量,在循环里只用一行代码:

def rob(self, nums: List[int]) -> int:
    prev = 0
    curr = 0
    
    # 每次循环,计算「偷到当前房子为止的最大金额」
    for i in nums:
        # 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2]
        # dp[k] = max{ dp[k-1], dp[k-2] + i }
        prev, curr = curr, max(curr, prev + i)
        # 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1]

    return curr

总结

本文用打家劫舍问题展示了动态规划问题的基本解题四步骤。动态规划问题种类繁多,有些题目非常有难度,但这些问题千变万化也离不开这四个基本解题步骤,只不过是把四步骤的某些步骤变得更难了。例如:

  • 在「定义子问题」的步骤,有的题目需要定义二维、三维的子问题。
  • 在「子问题递推关系」的步骤,有的题目需要分情况讨论,有复杂的 max、min、sum 表达式。
  • 在「DP 数组计算顺序」的步骤,有的题目需要反向计算,甚至斜向计算。
  • 在「空间优化」的步骤,有的题目需要使用临时变量,使用特殊的计算顺序。

那么你可能要问,题目千变万化,怎么才能全部学会呢?答案是由易到难、循序渐进,吃透例题,触类旁通。

看到这里,恭喜你走好了动态规划入门的第一步。接下来,我会循序渐进地讲解其他动态规划题目,让你能逐步掌握动态规划问题的技巧,敬请期待。


推荐阅读

•   60 个相见恨晚的神器工具•   一线互联网公司技术面试的流程以及注意事项•   自学编程的八大误区!克服它!•   新手如何有效的刷算法题(LeetCode)•   10款VS Code插件神器,第7款超级实用!•   在拼多多上班,是一种什么样的体验?我tm心态崩了呀!•   写给小白,从零开始拥有一个酷炫上线的网站!


欢迎关注我的公众号“五分钟学算法”,如果喜欢,麻烦点一下“在看”~

LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤

原文始发于微信公众号(五分钟学算法):LeetCode 例题精讲 | 14 打家劫舍问题:动态规划的解题四步骤