• 从 1 开始被动态规划玩转 (上)


    从 1 开始被动态规划玩转 (上)

    前言 (与本文无关, 可忽略~)

    广告推荐的文章最近没写, 核心原因是花里胡哨的 Trick 在实践中并无甚用, 影响写作的积极性 (本质上是人堕落了…). 在思考写作内容时, 考虑到可以先切换下主题, 比如动态规划, 这是我比较感兴趣的问题, 之前就想稍微系统的总结下, 写了部分内容在草稿箱, 现今打算完成. 标题取得有些奇特, 理由很简单, 因为在可见的未来并不能玩转动态规划, 另外写了 “(上)” 不代表有 “(下)”, 对目前的自己不抱有这样的期待.

    广而告之

    可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号;另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中. CSDN 上的阅读体验会更好一些, 地址是: CSDN

    文章总览

    本文通过对一定数量的动态规划问题的分析, 试图找寻求解这类问题的 “规律”. 在行文上, 尽量对能使用同一方法解决的问题进行聚类, 然后逐题进行思路分析与解答. 这样的好处是, 当介绍完一种思路后, 可以尝试用该思路去解决相似的问题, 以验证自己的掌握情况.
    文章不介绍基础的动态规划概念(标题中所谓的 “从 1 开始”), 比如 “重叠子问题”、“最优子结构” 以及 “状态转移方程” 等, 开始选的题目都不难, 可以在做题时可以深刻体会这些概念.
    编程语言采用 C++, 刷题时用到的语法并不复杂, 很容易转换为 Python/Java 等语言. 另外解法可能不是最优的, 比如可以进一步优化效率与空间, 但重点还是放在基本思路的阐述上.
    文字比较多, 采用 Ctrl + F 搜索标题的方式来进行查阅会比较方便.

    一维数组: dp[i] 表示以 nums[i] 结尾的 xxx

    一般我们用 dp (dynamic programming) 数组来表示状态转移方程, 对一维数组的问题, 常用的一个套路是令

    dp[i] 表示以 nums[i] 结尾的 xxx

    并开始分析 dp[i]dp[i - 1] 或者 dp[i - j] 之间的关系, 从而完成问题的求解. 具体可以看如下几道习题.

    最大子序和 [Easy]

    题目链接: 53. 最大子数组和

    给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
    
    示例 1:
    输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
    输出:6
    解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
    
    提示:
    1 <= nums.length <= 10^5
    -10^4 <= nums[i] <= 10^4
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    最大子序和是一道经典的动态规划题目, 它要统计子数组的和, 并返回其中最大的值.

    要写出状态转移方程, 需要先定义 dp 数组的含义. 该题中变化的量只是各个元素, 因此只需要用一维数组来保存各个阶段的状态. 定义 dp[i] 表示以 nums[i] 结尾的子数组的最大和, 那么, 现在要查找 dp[i]dp[i - 1] 的关系:

    • 如果 dp[i - 1] + nums[i] < nums[i], 就是说 dp[i -1] 对应的子数组最大和小于 0, 考虑到 dp[i] 对应的子数组必须以 nums[i] 结尾, 那么 dp[i - 1] 对应的子数组就可以舍弃, 而直接从 nums[i] 重新构建子数组;
    • 而如果 dp[i - 1] + nums[i] > nums[i], 那么此时 nums[i] 就可以加入到 dp[i - 1] 对应的子数组中.
      因此状态转移方程为 dp[i] = max(dp[i - 1] + nums[i], nums[i]). 另外使用 res 来保存所有子数组中的最大和.

    动态规划中还需要设置好初始情况, 假设 i = 0, dp[0] 就和 dp[-1] 有关, 一方面 dp[-1] 可以初始化为 0; 另一方面由于不存在 -1 索引, 所以给 dp 数组大小设置为 N + 1, 其中 Nnums 的大小.
    这样的话, 相当于使用 dp[i + 1] 来保存以 nums[i] 为结尾的子数组的最大和. 因此下面代码中, 状态转移方程写成了 dp[i + 1] = max(dp[i] + nums[i], nums[i]).

    class Solution {
    public:
        int maxSubArray(vector<int>& nums) {
            vector<int> dp(nums.size() + 1, 0);
            int res = INT32_MIN;
            for (int i = 0; i < nums.size(); ++ i) {
                dp[i + 1] = max(dp[i] + nums[i], nums[i]);
                res = max(res, dp[i + 1]);
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    写完动态规划的基本代码后, 需要再看下能否优化, 容易注意到 dp[i+1] 只和它前一个状态 dp[i] 有关, 所以只需要用一个变量来保存前一个状态的结果. 因此优化为如下代码, 减少空间占用.

    class Solution {
    public:
        int maxSubArray(vector<int>& nums) {
            int dp = 0;
            int res = INT32_MIN;
            for (int i = 0; i < nums.size(); ++ i) {
                dp = max(dp + nums[i], nums[i]);
                res = max(res, dp);
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    最长递增子序列 [Medium]

    题目链接: 300. 最长递增子序列

    给你一个整数数组 `nums` ,找到其中最长严格递增子序列的长度。
    
    子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
    
    示例 1:
    输入:nums = [10,9,2,5,3,7,101,18]
    输出:4
    解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
    
    提示:
    1 <= nums.length <= 2500
    -10^4 <= nums[i] <= 10^4
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    给定一个未排序的整数数组, 将其中最长递增子序列的长度求出来. 考虑使用动态规划来进行求解. 定义 dp[i] 表示以 nums[i] 结尾的数组中最长递增子序列的长度. 注意这个定义中说的 “以 nums[i] 结尾”. 为了得到状态转移方程, 需要考察 dp[i] 和其他元素的关系. 我们发现, 对于 j < i,

    • 如果 nums[i] > nums[j], 那么就可以在 dp[j] 对应的最长递增子序列末尾, 将 nums[i] 插入, 此时仍满足递增的性质, 这时候有 dp[i] = dp[j] + 1 (nums[i] > nums[j]);
    • 而如果 nums[i] <= nums[j], 说明 nums[i] 无法加入到 dp[j] 对应的最长递增子序列中, 又因为对于 dp[i] 的定义中说的是需要 “以 nums[i] 结尾”, 那么 dp[i] = 1, 表示此时以 nums[i] 结尾的数组中的最长递增子序列其实就是 nums[i] 本身, 大小为 1. (因此, dp 初始化时, 元素均设置为 1 很方便)

    归纳上面的分析, 可以得到状态转移方程为:

    for (int j = i - 1; j >= 0; -- j) {
        if (nums[i] > nums[j])
            dp[i] = max(dp[i], dp[j] + 1);
    }
    
    • 1
    • 2
    • 3
    • 4

    因此代码如下:

    class Solution {
    public:
        int lengthOfLIS(vector<int>& nums) {
        	int res = 0;
            vector<int> dp(nums.size(), 1);
            for (int i = 1; i < nums.size(); ++ i) {
                for (int j = i - 1; j >= 0; -- j) {
                    if (nums[i] > nums[j])
                        dp[i] = max(dp[i], dp[j] + 1);
                }
                res = max(res, dp[i]);
            }
            
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    举个例子, 下面列出数组 nums 以及数组中每个元素对应的 dp 值:

    nums: 4, 10, 4, 3, 8, 9
      dp: 1,  2, 1, 1, 2, 3
    
    • 1
    • 2

    最后求出 dp 中最大值为 3, 即 LIS 长度为 3, 比如 3, 8, 9 或者 4, 8, 9.

    这道题还有一种扩展解法 (可以暂时忽略, 因为下面的方法不是动态规划的方法, 可以先跳过):

    这道题的 Follow up 中提到可以使用 O(n log(n)) 的复杂度求解. 思路是尝试将数组中的最长递增子序列给找出来. 具体方法是:

    使用序列 r 来保存 nums 中的最长递增子序列. 遍历 nums 的每一个元素 v, 然后在数组 r 中找到 v 对应的 lower_bound, 即第一个大于或等于 v 的值.

    • 如果在序列 r 中找不到 v, 说明 vr 中的所有元素都大, 因此可以将 v 加入到 r 的末尾,
    • 而如果 r 中存在某元素(代码中用 *p 表示)大于或等于 v, 那么用 v 将这个元素替换, 这样的话, 一方面, 如果 *p 原本就等于 v, 那么没任何影响; 而如果 *p 原本大于 v, 那么此时更新成 v 后, 相当于数值变小了, 以后插入新的元素, 有更多得机会使得序列增长.

    代码如下:

    class Solution {
    public:
        int lengthOfLIS(vector<int>& nums) {
            vector<int> r;
            
            for(auto v : nums) {
                auto p = std::lower_bound(r.begin(), r.end(), v);
                if (r.end() == p)
                    r.push_back(v);
                else
                    *p = v;
            }
            return r.size();
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    乘积最大子数组 [Medium]

    题目链接: 152. 乘积最大子数组

    给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
    测试用例的答案是一个 32-位 整数。 子数组 是数组的连续子序列。
    
    示例 1:
    输入: nums = [2,3,-2,4]
    输出: 6
    解释: 子数组 [2,3] 有最大乘积 6。
    
    示例 2:
    输入: nums = [-2,0,-1]
    输出: 0
    解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
     
    
    提示:
    1 <= nums.length <= 2 * 10^4
    -10 <= nums[i] <= 10
    nums 的任何前缀或后缀的乘积都 保证 是一个 32-位 整数
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    乘积最大子数组 是一道经典的动态规划题目, 它和 最大子序和 以及 最长递增子序列 给我留下的印象太深刻了, 看到名字就知道需用动态规划的方法 🤣🤣🤣

    先定义状态转移方程, 比如 dp[i] 表示以 nums[i] 结尾的子数组的最大值 (乘积或者求和). 那么更新 dp[i + 1] 时, 对于上面介绍的 最大子序和 问题来说, 状态方程为 dp[i + 1] = max(dp[i] + nums[i], nums[i]);, 然而对于本题来说, 求最大乘积, 我们却不能直接使用 dp[i + 1] = max(dp[i] * nums[i], nums[i]); 原因是本题数组中是存在负数的, 即使 dp[i] 是负数, 也可能通过和 nums[i] 进行乘积, 从而得到正数. 那么矛盾就出现了, 我们在定义时, 希望 dp[i] 始终保存最大值, 但是就算 dp[i] 不是最大值, 也有可能在 dp[i + 1] 取得最大值 (通过负负得正的方式).

    那如何解决这个矛盾呢 ? 方法是使用两个 dp 数组, 其中数组 A 维护以 nums[i] 结尾的子数组的最大乘积值, 而引入数组 B 来维护以 nums[i] 结尾的子数组的最小值. 之后写状态方程时, 需要同步更新两个数组的状态.

    对于数组 A, 要维护每个元素始终表示 “以 nums[i] 结尾的子数组的最大乘积值”, 除了要比较 A[i - 1] * nums[i]nums[i] 大小外, 还需要考虑 B[i - 1] 这个最小值可能通过和 nums[i] 进行乘积的方式 (比如负负得正) 得到较大的正数, 因此更新 A[i] 时, 要比较 A[i - 1] * nums[i]B[i - 1] * nums[i] 以及 nums[i] 的大小:

    A[i] = max(max(A[i - 1] * nums[i], B[i - 1] * nums[i]), nums[i]);
    
    • 1

    同理对于数组 B, 要维护每个元素始终表示 “以 nums[i] 结尾的子数组的最小乘积值”, 也要比较 A[i - 1] * nums[i]B[i - 1] * nums[i] 以及 nums[i] 的大小, 不过这里是比较它们中的最小值, 毕竟 A[i - 1] * nums[i] 也可能因为 A[i - 1] 为正数而 nums[i] 为负数, 导致得到的乘积结果最小:

    B[i] = min(min(A[i - 1] * nums[i], B[i - 1] * nums[i]), nums[i]);
    
    • 1

    在实际编码时, 需要注意:

    • 考虑到遍历数组时 i0 开始, 那么当 i == 0 时, A[i - 1] 就是 A[-1], 越界了, 为此, 我们可以定义 A[i + 1] 表示以 nums[i] 结尾的子数组的最大乘积, 这种技巧应该很容易就想到;
    • AB 的初始化, 比如 A[0]B[0] 均初始化为 1, 用于乘积;

    下面看具体代码:

    class Solution {
    public:
        int maxProduct(vector<int>& nums) {
            vector<int> A(nums.size() + 1, 1), B(nums.size() + 1, 1);
            int res = nums[0];
            for (int i = 0; i < nums.size(); ++ i) {
                A[i + 1] = max(max(A[i] * nums[i], B[i] * nums[i]), nums[i]);
                B[i + 1] = min(min(A[i] * nums[i], B[i] * nums[i]), nums[i]);
                res = max(res, A[i + 1]);
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    由于 A 维护最大值, 所以最后其实是求 A 中的最大值, 使用 *std::max_element(A.begin(), A.end()) 来得到结果也是可以的.

    写完代码后, 观察下是否能再优化一下, 比如 A[i + 1] 只和 A[i] 相关, 只需要使用一个变量来表示 A[i] 即可, 同理 B[i] 也如此, 这样可以优化空间的占用. 代码如下:

    class Solution {
    public:
        int maxProduct(vector<int>& nums) {
            int A = 1, nA = 1, B = 1, nB = 1;
            int res = nums[0];
            for (int i = 0; i < nums.size(); ++ i) {
                nA = max(max(A * nums[i], B * nums[i]), nums[i]);
                nB = min(min(A * nums[i], B * nums[i]), nums[i]);
                A = nA;
                B = nB;
                res = max(res, A);
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    下面另一种解法更为巧妙一些:

    class Solution {
    public:
        int maxProduct(vector<int>& nums) {
            if (nums.empty()) return 0;
            int imax = nums[0], imin = nums[0];
            int res = nums[0];
            for (int i = 1; i < nums.size(); ++i) {
                if (nums[i] < 0) std::swap(imax, imin);
                imax = max(nums[i], imax * nums[i]);
                imin = min(nums[i], imin * nums[i]);
                res = max(res, imax);
            }
            return res;
        }
    }; 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    下面分析另一种套路.

    一维数组: dp[i] 表示达到 i 时的最值状态

    在该套路下, 可以使用 dp[i] 表示达到数组中第 i 个位置时的最大收益或者最小损失. 下面分析几个典型问题.

    使用最小花费爬楼梯 [Easy]

    题目链接: 746. 使用最小花费爬楼梯

    给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
    你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
    请你计算并返回达到楼梯顶部的最低花费。
    
    示例 1:
    输入:cost = [1,100,1,1,1,100,1,1,100,1]
    输出:6
    解释:你将从下标为 0 的台阶开始。
    - 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
    - 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
    - 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
    - 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
    - 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
    - 支付 1 ,向上爬一个台阶,到达楼梯顶部。
    总花费为 6 。
    
    提示:
    2 <= cost.length <= 1000
    0 <= cost[i] <= 999
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    对我来说这是一道迷人的题, 这题的神奇之处在于每次写的时候总感觉自己会写错, 但次次都能写对~ 之所以会产生这样的想法, 是因为我之前并没有将求解方法纳入现有的思考体系中.

    本题采用动态规划求解, 使用 dp[i] 表示达到 i 时所需要付出的最小代价. 假设 cost 数组的大小为 N, 最终的目标就是跳到第 N 个位置上, 所付出的代价就是 dp[N]. 根据题意, 当你位于第 i 个位置时, 付出当前位置的花费 cost[i], 就可以跳一步或者两步. 那么要跳到第 i 个位置上时, 要么一开始就位于第 i - 1 的位置上, 然后付出 cost[i - 1] 的代价跳一步到达第 i 个位置; 要么一开始位于第 i - 2 的位置上, 然后付出 cost[i - 2] 的代价跳两步到达第 i 个位置;
    综上, 状态转移方程呼之欲出:

    dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
    
    • 1

    要确定初始状态, 题目中说可以从第 0 个位置和第 1 个位置开始起跳, 那么:

    dp[0] = 0;
    dp[1] = 0;
    
    • 1
    • 2

    再次注意 dp[i] 的含义是到达 i 时所需要付出的最小代价, 该位置上的花费 cost[i] 指的是你要离开 第 i 个位置时需要付出的代价, 由于我们可以直接从第 0 个位置和第 1 个位置开始起跳, 所以它们的初始值均设置为 0. 最后我们只需要返回 dp[N] 就可以求解, 代码如下:

    class Solution {
    public:
        int minCostClimbingStairs(vector<int>& cost) {
            int N = cost.size();
            vector<int> dp(N + 1, 0);
            for (int i = 2; i <= N; ++ i)
                dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
            return dp[N];
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    注意题目中说了数组的大小至少为 2, 所以 N < 2 的情况不用考虑. 写到这, 代码还不算完, 还需要考虑能否进一步优化. 观察到 dp[i] 的结果只和 dp[i - 1]dp[i - 2] 有关, 似乎不需要使用 vector 来记录所有历史结果, 只需要使用变量 dp0 = dp[i - 2], dp1 = dp[i - 1] 来保留历史状态, 同时不断维护 dp0dp1 的状态.

    class Solution {
    public:
        int minCostClimbingStairs(vector<int>& cost) {
            int N = cost.size();
            int dp0 = 0, dp1 = 0, dp2 = 0;
            for (int i = 2; i <= N; ++ i) {
                dp2 = min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
                dp0 = dp1;
                dp1 = dp2;
            }
            return dp2;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    打家劫舍 [Medium]

    题目链接: 198. 打家劫舍

    你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋
    装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
    给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
    
    示例 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 。
     
    
    提示:
    1 <= nums.length <= 100
    0 <= nums[i] <= 400
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    这题的大意是小偷抢劫房子, 但不能抢劫相邻的, 求能获取的最大收益. 那么对于每所房子来说, 它有被抢和不抢两种状态, 设 dp[i] 为访问第 i 所房子时, 小偷能获得的最大收益, 那么状态转移方程就可以写成:

    dp[i] = max(dp[i - 2] + nums[i], // 抢劫第 i 所房子
               dp[i - 1]  // 不抢
               )
    
    • 1
    • 2
    • 3

    即如果抢劫了第 i 所房子, 那么它前一所房子(第 i - 1 所房子)就不能抢, 因此收益最大为 dp[i - 2] + nums[i]; 而如果不抢第 i 所房子, 那么收益最大为访问到第 i - 1 所房子时所获得收益 dp[i - 1].

    代码如下:

    class Solution {
    public:
        int rob(vector<int>& nums) {
            if (nums.empty()) return 0;
            if (nums.size() == 1) return nums[0];
            int n = nums.size();
            vector<int> dp(n, 0);
            dp[0] = nums[0];
            dp[1] = max(nums[0], nums[1]);
            for (int i = 2; i < n; ++ i) {
                dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
            }
            return dp.back();
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    如果不想判断 nums.size() == 1 的状态, 也可以将代码精简为:

    class Solution {
    public:
        int rob(vector<int>& nums) {
            vector<int> dp(nums.size(), 0);
            dp[0] = nums[0];
            for (int i = 1; i < nums.size(); ++ i) {
                dp[i] = std::max(i - 2 >= 0 ? (dp[i - 2] + nums[i]) : nums[i], dp[i - 1]);
            }
            return dp.back();
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    打家劫舍 II [Medium]

    题目链接: 213. 打家劫舍 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 。
     
    
    提示:
    1 <= nums.length <= 100
    0 <= nums[i] <= 1000
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    本题是上一题 打家劫舍 的进阶, 增加了房屋成环的约束条件. 处理成环问题的一个常用思路是将环进行展开, 假设房屋总数是 N, 那么想象用剪刀从位置 N - 1 和位置 0 之间剪开了一个口子. 将想象的情景落地成现实, 即是:

    • 偷窃计划 1: 如果小偷从第 0 间房屋开始偷窃, 那么它最多偷到第 N - 2 间屋子 (也就是说, 最后一间屋子 N - 1 绝对不偷)
    • 偷窃计划 2: 如果小偷从第 1 间房屋开始偷窃, 那么它最多偷到第 N - 1 间屋子

    通过这样的拆解, 本题就转换成了两道 打家劫舍 的问题, 不过处理的序列长度从原来的 N 变成了 N - 1, 打家劫舍 的代码可以直接拿过来用.

    dp1[i] 为按照 偷窃计划 1 访问第 i 所房子时, 小偷能获得的最大收益, dp2[i] 为按照 偷窃计划 2 访问第 i 所房子时, 小偷能获得的最大收益, 转移转移方程均为:

    dp[i] = max(dp[i - 2] + nums[i], // 抢劫第 i 所房子
               dp[i - 1]  // 不抢
               )
    
    • 1
    • 2
    • 3

    最后只需要比较 dp1dp2 中的最大值即可. 代码如下:

    class Solution {
    public:
        int rob(vector<int>& nums) {
            if (nums.size() == 1) return nums[0];
            vector<int> dp1(nums.size(), 0), dp2(nums.size(), 0);
            int res1 = nums[0], res2 = nums[1];
            // 按偷窃计划 1 行事, 访问数组 nums[0, ..., N - 2]
            dp1[0] = nums[0];
            for (int i = 1; i < nums.size() - 1; ++ i) {
                dp1[i] = std::max(i - 2 >= 0 ? dp1[i - 2] + nums[i] : nums[i], dp1[i - 1]);
                res1 = std::max(res1, dp1[i]);
            }
            // 按偷窃计划 2 行事, 访问数组 nums[1, ..., N - 1]
            dp2[1] = nums[1];
            for (int i = 2; i < nums.size(); ++ i) {
                dp2[i] = std::max(i - 2 >= 1 ? dp2[i - 2] + nums[i] : nums[i], dp2[i - 1]);
                res2 = std::max(res2, dp2[i]);
            }
            return std::max(res1, res2);
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    二维数组: dp[i][j] 表示到达 (i, j) 时的目标和

    当问题扩展到二维数组时, 位置 (i, j) 由两个变量决定, 相应的转态转移方程也要升级为 dp[i][j], 之后一般考察它与 dp[i][j - 1]dp[i - 1][j] 以及 dp[i - 1][j - 1] 的关系.

    不同路径 [Easy]

    题目链接: 62. 不同路径

    一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
    机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
    问总共有多少条不同的路径?
    
    示例 1:
    输入:m = 3, n = 7
    输出:28
    
    示例 2:
    输入:m = 3, n = 2
    输出:3
    解释:
    从左上角开始,总共有 3 条路径可以到达右下角。
    1. 向右 -> 向下 -> 向下
    2. 向下 -> 向下 -> 向右
    3. 向下 -> 向右 -> 向下
    
    提示:
    1 <= m, n <= 100
    题目数据保证答案小于等于 2 * 109
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    题目意图很明了, 要统计在一个 m x n 大小的网格中, 机器人从左上角 (0, 0) 处出发, 到达右下角 (m - 1, n - 1) 所有不同路径的总数, 机器人每次只能右移或下移一步.

    该题的变量主要是位置 (i, j), 使用二维数组 dp[i][j] 表示机器人到达 (i, j) 时的不同路径总数. 由于机器人每次只能右移或下移一步, 那么, 为了到达 (i, j), 机器人要么是从上方 (i, j - 1) 的位置下移一步到达 (i, j), 要么是从左侧 (i - 1, j) 右移一步到达 (i, j). 由于到达上方 (i, j - 1) 的路径有 dp[i][j - 1] 条, 而到达左侧 (i - 1, j) 的路径有 dp[i - 1][j] 条, 因此到达 (i, j) 时的路径总数为:

    dp[i][j] = dp[i][j - 1] + dp[i - 1][j]
    
    • 1

    确定好状态转移方程后, 再考虑 Base 状态和边界条件, 以求得正确的结果. 由于在 (0, 0) 这个位置的状态方程为 dp[0][0] = dp[0][-1] + dp[-1][0], 访问索引 -1 会导致数组越界, 因此常用的一种方法是在网格外围再扩展一层, 使网格的大小由 m x n 变化为 (m + 1) x (n + 1), 具体如下图:

    此时, dp[i + 1][j + 1] 表示到达原始网格 (i, j) 处的路径个数. 令 dp 初始化为 0, 并令 dp[0][1] = 1, 这是因为机器人在原始网格起点处 (0, 0) 的位置可以认为有 1 条路径能达到, 那么 dp[1][1] = dp[1][0] + dp[0][1] = 0 + 1 = 1 可以得到正确的结果. 正确的初始化之后, 可以写出如下代码:

    class Solution {
    public:
        int uniquePaths(int m, int n) {
            vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
            dp[0][1] = 1;
            for (int i = 1; i <= m; ++ i) {
                for (int j = 1; j <= n; ++ j) {
                    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
                }
            }
            return dp[m][n];
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    不同路径 II [Easy]

    题目链接: 63. 不同路径 II

    一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
    机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
    现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
    网格中的障碍物和空位置分别用 10 来表示。
    
    示例 1:
    输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
    输出:2
    解释:3x3 网格的正中间有一个障碍物。
    从左上角到右下角一共有 2 条不同的路径:
    1. 向右 -> 向右 -> 向下 -> 向下
    2. 向下 -> 向下 -> 向右 -> 向右
    
    提示:
    m == obstacleGrid.length
    n == obstacleGrid[i].length
    1 <= m, n <= 100
    obstacleGrid[i][j]01
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在上一题 不同路径 的基础上, 增加限制, 即如果某些空格是不能走的, 存在障碍物, 那么到达 grid 的右下角有多少种走法.

    方法仍是在网格外围再扩展一层, 使网格的大小由 m x n 变化为 (m + 1) x (n + 1), 同时令 dp[i + 1][j + 1] 表示到达原始网格 (i, j) 处的路径个数, 不过需要注意的是, 仅在 obstacleGrid[i][j] != 1 时, dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 成立.

    完整代码如下:

    class Solution {
    public:
        int uniquePathsWithObstacles(vector<vector<int> > &obstacleGrid) {
            int m = obstacleGrid.size() , n = obstacleGrid[0].size();
            vector<vector<long long>> dp(m+1,vector<long long>(n+1,0));
            dp[0][1] = 1;
            for(int i = 1 ; i <= m ; ++i)
                for(int j = 1 ; j <= n ; ++j)
                    if(!obstacleGrid[i-1][j-1])
                        dp[i][j] = dp[i-1][j] + dp[i][j-1];
            return dp[m][n];
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    注意 dp 被设置为 vector>, 是因为如果设置为 vector>, 那有个测试用例会导致报错:

    Line 44: Char 45: runtime error: signed integer overflow: 1053165744 + 1579748616 cannot be represented in type 'int' (solution.cpp)
    1
    
    • 1
    • 2

    设置为 long long 可以避免. 但两年前的提交代码中使用的就是 vector> 能通过 100% 的测试用例. 时代变了啊.

    最小路径和 [Easy]

    题目链接: 64. 最小路径和

    给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
    说明:每次只能向下或者向右移动一步。
    
    示例 1:
    输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
    输出:7
    解释:因为路径 1→3→1→1→1 的总和最小。
    
    示例 2:
    输入:grid = [[1,2,3],[4,5,6]]
    输出:12
     
    提示:
    m == grid.length
    n == grid[i].length
    1 <= m, n <= 200
    0 <= grid[i][j] <= 100
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    这题是 不同路径 的变种, 此处考虑用 dp[i][j] 表示到达 (i - 1, j - 1) 时最小的数字总和 (对原网格向外扩展一层), 由于每次只能向下或向右移动一步, 因此可以轻松的写出状态转移方程:

     dp[i][j] = std::min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
    
    • 1

    完整代码如下:

    class Solution {
    public:
        int minPathSum(vector<vector<int>>& grid) {
            int m = grid.size(), n = grid[0].size();
            vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT32_MAX));
            dp[0][1] = 0;
            for (int i = 1; i <= m; ++ i) {
                for (int j = 1; j <= n; ++ j) {
                    dp[i][j] = std::min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
                }
            }
            return dp[m][n];
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    三角形最小路径和 [Medium]

    题目链接: 120. 三角形最小路径和

    给定一个三角形 triangle ,找出自顶向下的最小路径和。
    每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者
    等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到
    下一行的下标 i 或 i + 1 。
    
    示例 1:
    输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
    输出:11
    解释:如下面简图所示:
       2
      3 4
     6 5 7
    4 1 8 3
    自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
    
    示例 2:
    输入:triangle = [[-10]]
    输出:-10
     
    
    提示:
    1 <= triangle.length <= 200
    triangle[0].length == 1
    triangle[i].length == triangle[i - 1].length + 1
    -10^4 <= triangle[i][j] <= 10^4
     
    进阶:
    你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题吗?
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    dp[i][j] 表示到达 (i, j) 时, 自顶向下的最小路径和. 对于节点 (i, j), 它要么是从上一层位于 (i - 1, j - 1) 的节点移动到的, 要么是从上一层位于 (i - 1, j) 的节点移动到的. 因此可以很轻松的写出状态转移方程:

    dp[i][j] = triangle[i - 1][j - 1] + std::min(dp[i - 1][j - 1], dp[i - 1][j]);
    
    • 1

    但由于访问的是三角形而不是矩形, 因此有些边界条件需要注意, 完整代码如下, 在具体实现时, 考虑到边界情况, 可以将三角形向外扩展一层, 这种方式在前几道题有介绍, 这里不再详细说明. 另外注意当 j > i 时, 三角形中没有元素, 此时可以 continue:

    class Solution {
    public:
        int minimumTotal(vector<vector<int>>& triangle) {
            int n = triangle.size();
            vector<vector<int>> dp(n + 1, vector<int>(n + 1, INT32_MAX));
            dp[0][0] = 0;
            int res = INT32_MAX;
            for (int i = 1; i <= n; ++ i) {
                for (int j = 1; j <= n; ++ j) {
                    if (j > i) continue;
                    dp[i][j] = triangle[i - 1][j - 1] + std::min(dp[i - 1][j - 1], dp[i - 1][j]);
                    if (i == n) res = std::min(res, dp[i][j]);
                }
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    另外也写了不使用额外空间的做法, 仅做参考, 核心在需要更改 triangle 变量本身的值:

    class Solution {
    public:
        int minimumTotal(vector<vector<int>>& triangle) {
            for (int i = 1; i< triangle.size(); ++ i) {
                for (int j = 0; j < triangle[i].size(); ++ j) {
                    int a = j - 1 >= 0 ? triangle[i - 1][j - 1] : INT32_MAX;
                    int b = j < triangle[i - 1].size() ? triangle[i - 1][j] : INT32_MAX;
                    triangle[i][j] += std::min(a, b);
                }
            }
            int res = INT32_MAX;
            for (auto &c : triangle.back()) res = std::min(res, c);
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    下降路径最小和 [Medium]

    题目链接: 931. 下降路径最小和

    给你一个 n x n 的 方形 整数数组 matrix ,请你找出并返回通过 matrix 的下降路径 的 最小和 。
    下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选
    元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置 (row, col) 
    的下一个元素应当是 (row + 1, col - 1)(row + 1, col) 或者 (row + 1, col + 1) 。
    
    示例 1:
    输入:matrix = [[2,1,3],[6,5,4],[7,8,9]]
    输出:13
    解释:如下图所示,为和最小的两条下降路径
    
    提示:
    n == matrix.length == matrix[i].length
    1 <= n <= 100
    -100 <= matrix[i][j] <= 100
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    经过前面问题的训练, 此题可以轻松做出来. 使用 matrix 本身来记录到达 (i, j) 时下降路径的最小和, 因此状态转移方程是:

    matrix[i][j] += std::min(
    	std::min(matrix[i - 1][j - 1], matrix[i - 1][j + 1]),
    	matrix[i - 1][j])
    
    • 1
    • 2
    • 3

    因为要到达 (i, j), 可以从上一层的 (i - 1, j - 1), (i - 1, j) 以及 (i - 1, j + 1) 三个位置移动, 实际编写代码时考虑写边界条件即可, 完整代码如下:

    class Solution {
    public:
        int minFallingPathSum(vector<vector<int>>& matrix) {
            if (matrix.size() == 1) return matrix[0][0];
            int res = INT32_MAX;
            for (int i = 1; i < matrix.size(); ++ i) {
                for (int j = 0; j < matrix[0].size(); ++ j) {
                    int a = j - 1 >= 0 ? matrix[i - 1][j - 1] : INT32_MAX;
                    int b = j + 1 < matrix[0].size() ? matrix[i - 1][j + 1] : INT32_MAX;
                    matrix[i][j] += std::min(std::min(a, b), matrix[i - 1][j]);
                    if (i == matrix.size() - 1) res = std::min(res, matrix[i][j]);
                }
            }
            return res;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    总结

    文章稍显冗长, 主要是题目本身也占用较多字数, 通过将相同类型的题目汇聚在一起, 可以发现还是有些通用的规律的. 不过一期选择的题目基本都非常简单, 没有太多的弯弯绕绕, 还欠缺一些挑战性. 后续将总结更 Tricky 的题目, 以便加深这可怜的记忆. 技能的习得需要大量练习, 奈何我还是想偷偷懒, 不是很想花太多时间在这上面, 哈哈哈.

  • 相关阅读:
    MATLAB函数
    ROS中的重名问题
    LM小型可编程控制器软件(基于CoDeSys)笔记二十九:查看
    1074 Reversing Linked List
    命令执行漏洞超详细讲解
    什么是Jmeter ?Jmeter使用的原理步骤是什么?
    急诊医学-急救医学-复习资料-总结-重点-笔记
    (五)CSS前端开发面试会问到的问题有哪些?
    8、学习 Java 中的方法(方法的定义、可变参数、参数的传递问题、方法重载、方法签名)通过官方教程
    【全志T113-S3_100ask】15-2 linux系统gpio模拟spi驱动屏幕——ILI9341
  • 原文地址:https://blog.csdn.net/Eric_1993/article/details/126045386