动态规划题目,状态定义很关键,超级关键。状态如何定义:靠积累、靠结合题目要求思考、靠举例归纳。
首先,这种解法是超时的,之所以还这么做的原因是为了在面试的时候保底。多练习递归或者暴力搜索法,以防在面试的时候,啥都不会。
爬楼梯抽象出来就是将一个整数不停地减小,每次只有两种选择:策略1:减去1;策略2:减2,直到减为0。问一共有多少种不同的策略组合方式。以3和4为例,问题的求解可以变成求解二叉树的分枝个数。注意,其中当节点为1或2时停止,因为当节点为1或2时,再往下分解其叶子节点的个数就是1或2.
典型的二叉树结构递归的问题,二叉树结构的每一层都有两种策略:减1或者减2,而所求的分枝个数,就是两种策略下的分枝个数之和。代码如下:
class Solution {
public:
int climbStairs(int n) {
if (n == 1 || n ==2) return n;
return climbStairs(n-1) + climbStairs(n-2);
}
};
结果超时,最后执行的输入是44。
分析超时的原因,子树被重复多次计算。以图2为例进行说明:
图2的左图时,计算过一次3的结果;但是进行到图2中,即计算4的时候,3又要再计算一次;同样在计算5的时候,4的子树又要再被计算一次。因此,从左往右观察图2,这个二叉树结构每增加一层,就有将近一半的节点要再被重新计算一次。
所以,如果从这个二叉树的叶子节点往根节点方向计算,同时用dp数组来存储每个子树根节点的值,那么就可以避免子树重复计算,时间复杂度自然就会降下来。修改代码如下:
class Solution {
public:
int climbStairs(int n) {
int dp[n+1];
for (int i=1; i<=n; i++) {
if (i<3) dp[i] = i;
else dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
};
其实这个就是动态规划解法。
动态规划方法,是利用问题各个阶段的关系,逐个求解,最终求得全局最优解。
本题的动态规划原理2:
代码如下:
class Solution {
public:
int climbStairs(int n) {
int dp[n+3]; // 对n=0时,不会出现dp数组越界的情况
// 边界条件
dp[1] = 1;
dp[2] = 2;
// 递推
for (int i=3; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
};
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
一般很难立刻想到题目对应的动态规划解法的四要素内容,需要通过枚举例子来慢慢寻找,本题的动态规划四要素是2:
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
int n = nums.size();
int dp[n+2];
// 边界条件
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
// 递推
for (int i=2; i<n; i++) {
dp[i] = max(dp[i-1], nums[i]+dp[i-2]);
}
return dp[n-1];
}
};
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
分析:
于是,该题的动态规划四要素是:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
int dp[nums.size()];
// 边界条件
dp[0] = nums[0];
int ans=dp[0];
// 递推
for (int i=1; i<nums.size(); i++) {
dp[i] = max(nums[i]+dp[i-1], nums[i]);
ans = ans>dp[i] ? ans:dp[i];
}
return ans;
}
};
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
分析:
枚举归纳递:
3 = 1 + (3-1) => dp[3] = 1 + dp[3-1] = 2;
3 = 2 + (3-2) => dp[3] = 1 + dp[3-2] = 2;
dp[3] = min(2,2) = 2;
4 = 1 + (4-1) => dp[4] = 1 + dp[4-1] = 3;
4 = 2 + (4-2) => dp[4] = 1 + dp[4-2] = 2;
dp[4] = min(2,3,2) = 2;
5 = 1 + (5-1) => dp[5] = 1 + dp[5-1] = 3;
5 = 2 + (5-2) => dp[5] = 1 + dp[5-2] = 3;
5 = 5 + (5-5) => dp[5] = 1 + dp[5-5] = 1;
dp[5] = min(3,3,1) = 1;
6 = 1 + (6-1) => dp[6] = 1 + dp[6-1] = 2;
6 = 2 + (6-2) => dp[6] = 1 + dp[6-2] = 3;
6 = 5 + (6-5) => dp[6] = 1 + dp[6-4] = 2;
dp[6] = min(2,3,2) = 2;
7 = 1 + (7-1) => dp[7] = 1 + dp[7-1] = 3;
7 = 2 + (7-2) => dp[7] = 1 + dp[7-2] = 2;
7 = 5 + (7-5) => dp[7] = 1 + dp[7-5] = 2;
dp[7] = min(3,2,2) = 2;
8 = 1 + (8-1) => dp[8] = 1 + dp[8-1] = 3;
8 = 2 + (8-2) => dp[8] = 1 + dp[8-2] = 3;
8 = 5 + (8-5) => dp[8] = 1 + dp[8-5] = 3;
dp[8] = min(3,3,3) = 3;
9 = 1 + (9-1) => dp[9] = 1 + dp[9-1] = 4;
9 = 2 + (9-2) => dp[9] = 1 + dp[9-2] = 3;
9 = 5 + (9-5) => dp[9] = 1 + dp[9-5] = 3;
dp[9] = min(4,3,3) = 3;
10 = 1 + (10-1) => dp[10] = 1 + dp[10-1] = 4;
10 = 2 + (10-2) => dp[10] = 1 + dp[10-2] = 4;
10 = 5 + (10-5) => dp[10] = 1 + dp[10-5] = 2;
dp[10] = min(4,4,2) = 2;
11 = 1 + (11-1) => dp[11] = 1 + dp[11-1] = 3;
11 = 2 + (11-2) => dp[11] = 1 + dp[11-2] = 4;
11 = 5 + (11-5) => dp[11] = 1 + dp[11-5] = 3;
dp[11] = min(4,4,3) = 3;
于是,该题的动态规划四要素是:
代码如下:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int dp[amount+1];
for (int i=0; i<amount+1; i++) dp[i] = -1;
// 边界条件
dp[0] = 0;
// 递推
for (int i=1; i<=amount; i++) {
for (int j=0; j<coins.size(); j++) {
if ( i - coins[j] >= 0 && dp[i-coins[j]] != -1) {
// 因为dp[i]=-1, 所以min()不适合用来更新dp[i]
if (dp[i] == -1 || dp[i] > dp[i-coins[j]]+1) {
dp[i] = dp[i-coins[j]]+1;
}
}
}
}
return dp[amount];
}
};
给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。
例如,给定三角形:
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)
分析:
从下往上和从上往下思考,哪个考虑的边界条件更少?从下往上考虑的更少。从下往上,上一层的元素(i,j)只取决与下一层中的(i+1,j)和(i+1,j+1),而且(i+1,j)和(i+1,j+1)一定会在三角形内。但是,如果从上往下考虑,那么下一层的元素(i,j)要取决于上一层的(i-1, j)和(i-1, j-1), 但是上一层的(i-1, j)和(i-1, j-1)可能会出界。
因为考虑边界条件少,所以选择从下往上递推。
定义状态,dp[i][j]表示为从最底层到元素(i,j)的最优解。状态dp[i][j]和子问题状态的关系通过枚举例子归纳得出:
以示例为例:
初始状态:dp[3][0] = 4, dp[3][1] = 1, dp[3][2] = 8, dp[3][3] = 3;
dp[2][0] = nums[2][0] + min(dp[3][0], dp[3][1]) = 6 + min(4,1) = 7;
dp[2][1] = nums[2][1] + min(dp[3][1], dp[3][2]) = 5 + min(1,8) = 6;
dp[2][2] = nums[2][2] + min(dp[3][2], dp[3][3]) = 7 + min(8,3) = 10;
dp[1][0] = nums[1][0] + min(dp[2][0], dp[2][1]) = 3 + min(7,6) = 9;
dp[1][1] = nums[1][1] + min(dp[2][1], dp[2][2]) = 4 + min(6,10) = 10;
dp[0][0] = nums[0][0] + min(dp[1][0], dp[1][1]) = 2 + min(9, 10) = 11;
状态转移公式为:dp[i][j] = nums[i][j] + min(dp[i+1][j], dp[i+1][j+1])
边界条件就是最底层元素;
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
if (triangle.size() == 0) return 0;
// 初始化dp[][]为0
vector<vector<int>> dp;
for (int i=0; i<triangle.size(); i++) {
dp.push_back(vector<int>());
for (int j=0; j<triangle[i].size(); j++) {
dp[i].push_back(0);
}
}
// 边界条件
dp.back() = triangle.back();
// 状态转移方程
for (int i=triangle.size()-2; i>=0; i--) {
for (int j=0; j<triangle[i].size(); j++) {
dp[i][j] = triangle[i][j] + min(dp[i+1][j], dp[i+1][j+1]);
}
}
return dp[0][0];
}
};
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
算法时间复杂度要求为 O ( n 2 ) O(n^2) O(n2)
考虑动态规划的解法,关键是如何确定第i个状态dp[i]表示什么?
有两种选择2:
(1) 像1.2打家劫舍那样,dp[i]表示前i个数字中的最优解,即前i个数字中最长上升子序列的长度。
(2) 像1.3最大和的连续子数组那样,dp[i]表示以第i个数字结尾的最优解,即以第i个数字结尾的最长上升子序列的长度。
选择哪种的依据是,能否找到dp[i]和dp[i-1]的关系!
或者还可以这么推理:定义了dp[i]之后,看看dp[i-1]、dp[i-2]、…有哪些会影响dp[i]的取值。很明显的一点是,以(2)定义状态i,很容易发现这一点。
例如, [10,9,2,5,3,7,101,18]:
dp[0] = 1;
dp[1]: nums[1] < nums[0] => dp[1] = 1;
dp[2]: nums[2] < nums[1] => dp[2] = 1;
dp[3]: nums[3] > nums[2] => dp[3] = dp[2] + 1 = 2;
dp[4]: nums[4] > nums[2] => dp[4] = dp[2] + 1 = 2;
dp[5]: nums[5] > nums[3] => dp[5] = dp[3] + 1 = 3;
dp[6]: nums[6] > nums[5] => dp[6] = dp[5] + 1 = 4;
dp[7]: nums[7] > nums[5] => dp[7] = dp[5] + 1 = 4;
而结果ans = max(dp[0], dp[1], dp[2],...,dp[7]) = 4;
所以状态转移方程是:
当 nums[i] > nums[j]时,
dp[i] = max(dp[i], 1+dp[j])
而且,如果j是从i开始递减,那么只需要在遇到第一个符合状态转移方程的状态终止即可。
代码如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size()==0) return 0;
// 状态定义为以第i个数字结尾的最长上升子序列长度
// 初始化dp值为1,同时也定义好了边界条件dp[0]=1
vector<int> dp(nums.size(), 1);
int ans=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], 1+dp[j]);
continue;
}
ans = ans>dp[i] ? ans:dp[i];
}
return ans;
}
};
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
通过上述的题目的分析,很容易想到这一题的状态dp[i][j],从左上角到(i,j)的最短距离。从而状态转移方程也就呼之欲出。本题与上面不同的就是边界条件,即第一行和第一列的元素不满足状态转移方程。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
vector<vector<int>> dp = grid;
// 边界条件
for (int i=1; i<grid[0].size(); i++) dp[0][i] += dp[0][i-1];
for (int i=1; i<grid.size(); i++) dp[i][0] += dp[i-1][0];
// 递推
for (int i=1; i<grid.size(); i++) {
for (int j=1; j<grid[0].size(); j++) {
dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
}
}
return dp[grid.size()-1][grid[0].size()-1];
}
};
要求使用 O ( n l o g n ) O(nlogn) O(nlogn)的时间复杂度实现。
核心关键:序列如果上升的越慢,那么就越有可能得到最长序列。
定义状态:st[i]表示长度为i+1的上升子序列的最后一个元素的最小可能取值。
与其他状态的联系:如果要组成一个长度为i+2的上升子序列,需要一个大于st[i]的元素。最终st的长度就是最长上升子序列的长度。
例如:
[10,9,2,5,3,7,101,7,18]:
(nums[0]) st = [10]
(nums[1]) st = [9]
(nums[2]) st = [2]
(nums[3]) st = [2,5]
(nums[4]) st = [2,3]
(nums[5]) st = [2,3,7]
(nums[6]) st = [2,3,7,101]
(nums[7]) st = [2,3,7,101]
(nums[8]) st = [2,3,7,18]
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size()==0) return 0;
vector<int> st;
st.push_back(nums[0]);
// 递推
for (int i=1; i<nums.size(); i++) {
if (nums[i] > st.back()) {
st.push_back(nums[i]);
}
else {
for (int j=0; j<st.size(); j++) {
// 如果小于号=> nums[7] st = [2,3,7,7],
// 从而nums[8] st = [2,3,7,7,18]
// 我们需要的是严格增的子序列
if (nums[i] <= st[j]) {
st[j] = nums[i];
break;
}
}
}
}
return st.size();
}
};
st内是有序的序列,可以通过二分查找来更新st内的值。
class Solution {
public:
int binary_search( vector<int> st, int target) {
int index = -1;
int left = 0;
int right = st.size() - 1;
while (index == -1) {
int mid = (left + right) >> 1;
if (st[mid] == target) index = mid;
else if (target < st[mid] ) {
if (mid == 0 || target > st[mid-1] ) {
index = mid;
}
right = mid - 1;
}
else {
if (mid == st.size() - 1 || target < st[mid+1]) {
index = mid + 1;
}
left = mid + 1;
}
}
return index;
}
int lengthOfLIS(vector<int>& nums) {
if (nums.size()==0) return 0;
vector<int> st;
st.push_back(nums[0]);
// 递推
for (int i=1; i<nums.size(); i++) {
if (nums[i] > st.back()) {
st.push_back(nums[i]);
}
else {
int pos = binary_search(st, nums[i]);
st[pos] = nums[i];
}
}
return st.size();
}
};
一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。
骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。
为了尽快到达公主,骑士决定每次只向右或向下移动一步。
编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。
例如,考虑到如下布局的地下城,如果骑士遵循最佳路径 右 -> 右 -> 下 -> 下,则骑士的初始健康点数至少为 7。
-2 (K) | -3 | 3 |
---|---|---|
-5 | -10 | 1 |
10 | 30 | -5 § |
思考状态如何定义:
(1) 如果按照2.4中的方法定义状态dp[i][j]:从左上角到房间(i,j)的最大血量,显然不是题目要求的至少血量。通过示例也能验证这个看法。因此,2.4的状态定义方式在本题行不通。
(2) 跳出2.4的思维固式,重新思考本题。除了像2.4那样从左上角往右下角递推,本题还可以从右下角往左上角递推。同时状态设计要和题目的要求"到右下角至少需要多少血量"联系起来。
(3) 依据(2)的思考方向,定义dp[i][j]:从房间(i,j)到右下角的最少血量,且 d p [ i ] [ j ] ≥ 1 dp[i][j] \geq 1 dp[i][j]≥1,因为骑士血量为0或低于0时就挂了。
(4) 有了状态dp[i][j]的定义,通过思考+枚举例子很容易归纳出状态转移方程。dp[i][j]和(i,j)的下面和右面有关,dp[i][j] = min(dp[i+1][j], dp[i][j+1]) - dp[i][j]; 同时 d p [ i ] [ j ] ≥ 1 dp[i][j] \geq 1 dp[i][j]≥1。
(5) 这种方式下,边界条件是最后一行和最后一列。
代码如下:
class Solution {
public:
int calculateMinimumHP(vector<vector<int>>& dungeon) {
if (dungeon.size() == 0) return 1;
vector<vector<int>> dp = dungeon;
// 边界条件
int m = dungeon.size()-1;
int n = dungeon[0].size()-1;
dp[m][n] = dp[m][n]>0?1:1-dp[m][n];
for (int i=n-1; i>=0; i--) {
dp[m][i] = dp[m][i+1] - dp[m][i];
if (dp[m][i] <= 0) dp[m][i] = 1;
}
for (int i=m-1; i>=0; i--) {
dp[i][n] = dp[i+1][n] - dp[i][n];
if (dp[i][n] <= 0) dp[i][n] = 1;
}
// 递推
for (int i=m-1; i>=0; i--) {
for (int j=n-1; j>=0; j--) {
dp[i][j] = min(dp[i+1][j], dp[i][j+1]) - dp[i][j];
if (dp[i][j] <= 0) dp[i][j] = 1;
}
}
return dp[0][0];
}
};
https://leetcode-cn.com/problems/climbing-stairs/ ↩︎
https://www.bilibili.com/video/BV1GW411Q77S?p=9 ↩︎ ↩︎ ↩︎
https://leetcode-cn.com/problems/house-robber ↩︎
https://leetcode-cn.com/problems/maximum-subarray/ ↩︎
https://leetcode-cn.com/problems/coin-change/ ↩︎
https://leetcode-cn.com/problems/triangle/ ↩︎
https://leetcode-cn.com/problems/longest-increasing-subsequence/ ↩︎ ↩︎
https://leetcode-cn.com/problems/minimum-path-sum/ ↩︎
https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/dong-tai-gui-hua-er-fen-cha-zhao-tan-xin-suan-fa-p/ ↩︎
https://leetcode-cn.com/problems/dungeon-game/ ↩︎