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

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

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

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

广而告之

可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号, 可以及时获取最新原创技术文章更新.

另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中.

文章总览

本文通过对一定数量的动态规划问题的分析, 试图找寻求解这类问题的 “规律”. 在行文上, 尽量对能使用同一方法解决的问题进行聚类, 然后逐题进行思路分析与解答. 这样的好处是, 当介绍完一种思路后, 可以尝试用该思路去解决相似的问题, 以验证自己的掌握情况.
文章不介绍基础的动态规划概念(标题中所谓的 “从 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

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

要写出状态转移方程, 需要先定义 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;
    }
};

写完动态规划的基本代码后, 需要再看下能否优化, 容易注意到 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;
    }
};

最长递增子序列 [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

给定一个未排序的整数数组, 将其中最长递增子序列的长度求出来. 考虑使用动态规划来进行求解. 定义 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);
}

因此代码如下:

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;
    }
};

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

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

最后求出 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();
    }
};

乘积最大子数组 [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-位 整数

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

先定义状态转移方程, 比如 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]);

同理对于数组 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]);

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

  • 考虑到遍历数组时 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;
    }
};

由于 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;
    }
};

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

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;
    }
}; 

下面分析另一种套路.

一维数组: 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

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

本题采用动态规划求解, 使用 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])

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

dp[0] = 0;
dp[1] = 0;

再次注意 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];
    }
};

注意题目中说了数组的大小至少为 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;
    }
};

打家劫舍 [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

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

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

即如果抢劫了第 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();
    }
};

如果不想判断 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();
    }
};

打家劫舍 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

本题是上一题 打家劫舍 的进阶, 增加了房屋成环的约束条件. 处理成环问题的一个常用思路是将环进行展开, 假设房屋总数是 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]  // 不抢
           )

最后只需要比较 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);
    }
};

二维数组: 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

题目意图很明了, 要统计在一个 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]

确定好状态转移方程后, 再考虑 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];
    }
};

不同路径 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

在上一题 不同路径 的基础上, 增加限制, 即如果某些空格是不能走的, 存在障碍物, 那么到达 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];
    }
};

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

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

设置为 long long 可以避免. 但两年前的提交代码中使用的就是 vector<vector<int>> 能通过 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

这题是 不同路径 的变种, 此处考虑用 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];

完整代码如下:

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];
    }
};

三角形最小路径和 [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 为三角形的总行数)来解决这个问题吗?

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]);

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

另外也写了不使用额外空间的做法, 仅做参考, 核心在需要更改 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;
    }
};

下降路径最小和 [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

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

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

因为要到达 (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;
    }
};

总结

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值