学习线性DP之前,请确保已经对递推有所了解。
一、概念
1、动态规划
不要去看网上的各种概念,什么无后效性,什么空间换时间,会越看越晕。从做题的角度去理解就好了,动态规划就可以理解成一个 有限状态自动机,从一个初始状态,通过状态转移,跑到终止状态的过程。
2、线性动态规划
线性动态规划,又叫线性DP,就是在一个线性表上进行动态规划,更加确切的说,应该是状态转移的过程是在线性表上进行的。我们考虑有 0 到 n 这 n+1 个点,对于第 i 个点,它的值取决于 0 到 i-1 中的某些点的值,可以是求 最大值、最小值、方案数 等等。

很明显,如果一个点 i 可以从 i-1 或者 i-2 过来,求到达第 i 号点的方案数,就是我们之前学过的斐波那契数列了,具体可以参考这篇文章:递推。
二、例题解析
1、题目描述
给定一个 n,再给定一个 n(n ≤ 1000) 个整数的数组 cost, 其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦支付此费用,即可选择向上爬 1个 或者 2个 台阶。可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯,请计算并返回达到楼梯顶部的最低花费。
2、算法分析
我们发现这题和之前的爬楼梯很像,只不过从原来的计算 方案数 变成了计算 最小花费 。尝试用一个数组来表示状态:f[i] 表示爬到第 i 层的最小花费。
由于每次只能爬 1个或者 2个台阶,所以 f[i] 这个状态只能从 f[i-1] 或者 f[i-2] 转移过来:
1)如果从 i-1 层爬上来,需要的花费就是 f[i-1] + cost[i-1];
2)如果从 i-2 层爬上来,需要的花费就是 f[i-2] + cost[i-2];
没有其他情况了,而我们要 求的是最小花费,所以 f[i] 就应该是这两者的小者,得出状态转移方程:
f[i] = min(f[i-1] + cost[i-1], f[i-2] + cost[i-2])
然后考虑一下初始情况 f[0] 和 f[1],根据题目要求它们都应该是 0。
3、源码详解
cpp
int min(int a, int b) {
return a < b ? a : b; // (1)
}
int minCostClimbingStairs(int* cost, int n){
int i; // (2)
int f[1001] = {0, 0}; // (3)
for(i = 2; i <= n; ++i) { // (4)
f[i] = min(f[i-1] + cost[i-1], f[i-2] + cost[i-2]);
}
return f[n]; // (5)
}
- (1) 为了方便求最小值,我们实现一个最小值函数 min,直接利用 C语言 的 条件运算符 就可以了;
- (2) 然后开始动态规划的求解,首先定义一个循环变量;
- (3) 再定义一个数组 f[i] 代表从第 0 阶爬到第 i 阶的最小花费,并且初始化第 0 项 和 第 1 项;
- (4) 然后一个 for 循环,从第 2 项开始,直接套上状态转移方程就能计算每一项的值了;
- (5) 最后返回第 n 项即可;
三、再谈动态规划
经典的线性DP有很多,比如:最长递增子序列、背包问题 是非常经典的线性DP了。建议先把线性DP搞清楚以后再去考虑其它的动态规划问题。
而作为动态规划的通解,主要分为以下几步:
** 1、设计状态**
** 2、写出状态转移方程**
** 3、设定初始状态**
** 4、执行状态转移**
** 5、返回最终的解**
一、基本概念
学习动态规划,如果一上来告诉你:最优子结构、重叠子问题、无后效性 这些抽象的概念,那么你可能永远都学不会这个算法,最好的方法就是从一些简单的例题着手,一点一点去按照自己的方式理解,而不是背概念。
对于动态规划问题,最简单的就是线性动态规划,这堂课我们就利用一些,非常经典的线性动态规划问题来进行分析,从而逐个击破。
二、常见问题
1、爬楼梯
-
问题描述:有一个 n 级楼梯,每次可以爬 1 或者 2 级。问有多少种不同的方法可以爬到第 n 级。
-
状态:dp[i] 表示爬到第 i 级楼梯的方案数。
-
初始状态:dp[0] = dp[1] = 1
-
状态转移方程:dp[i] = dp[i-1] + dp[i-2]。 (对于爬到第 i 级,可以从 i-1 级楼梯爬过来,也可以从 i-2 级楼梯爬过来)
-
状态数:O(n)
-
状态转移消耗:O(1)
-
时间复杂度:O(n)
javaclass Solution { public: int climbStairs(int n) { vector<int> dp(n+1); dp[0] = dp[1] = 1; for (int i = 2; i < dp.size(); i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[n]; } };
2、 最大子数组和(最大子段和)
- 问题描述:给定一个 n 个元素的数组 arr[],求一个子数组,并且它的元素和最大,返回最大的和。
- 状态:dp[i] 表示以第 i 个元素结尾的最大子数组和。
- 初始状态:dp[0] = arr[0](可以为负数)
- 状态转移方程:dp[i] = arr[i] + max(dp[i-1], 0)。 (因为是以第i个元素结尾,所以 arr[i]必选, dp[i-1] 这部分是以第 i-1 个元素结尾的,可以不选或者选,完全取决于它是否大于0,所以选和不选取大者)
- 状态数:O(n)
- 状态转移消耗:O(1)
- 时间复杂度:O(n)
java
class Solution {
public:
int maxSubArray(vector<int>& arr) {
vector<int> dp(arr.size()+1);
dp[0] = arr[0];
int maxSum=dp[0];
for(int i=1;i<arr.size();i++)
{
dp[i] = arr[i] + max(dp[i-1], 0);
maxSum = max(maxSum, dp[i]);
}
return maxSum;
}
};
还有一个双O(1)的方法
class Solution {
public:
int maxSubArray(vector<int>& arr) {
if (arr.empty()) return 0;
int currentSum = arr[0];
int maxSum = arr[0];
for (int i = 1; i < arr.size(); ++i) {
currentSum = max(currentSum + arr[i], arr[i]);
maxSum = max(maxSum, currentSum);
}
return maxSum;
}
};
3、最长递增子序列
- 问题描述:给定一个 n 个元素的数组 arr[],求一个最大的子序列的长度,序列中元素单调递增。
- 状态:dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。
- 初始状态:dp[0] = 1
- 状态转移方程:dp[i] = max(dp[i], dp[j] + 1)。(arr[j] < arr[i]) (对于所有下标比 i 小的下标 j,并且满足 arr[j] < arr[i] 的情况,取所有这里面 dp[j] 的最大值 加上 1 就是 dp[i] 的值,当然可能不存在这样的 j,那么这时候 dp[i] 的值就是 1)
- 状态数:O(n)
- 状态转移消耗:O(n)
- 时间复杂度:O(n^2)
java
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int maxlength = 1;
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + 1);
maxlength = max(maxlength, dp[i]);
}
}
}
return maxlength;
}
};
4、数字三角形
- 问题描述:给定一个 n 行的三角形 triangle[][],找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1。
- 状态:dp[i][j] 表示从顶部走到 (i, j) 位置的最小路径和。
- 初始状态:dp[0][0] = triangle[0][0];起点就是顶部,路径和只能是它自己。
- 状态转移方程:dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j]。(走到
(i,j)
的路径只能从两个方向来:从左上方来(即从(i-1, j-1)
走到(i,j)
)从上方来(即从(i-1, j)
走到(i,j)
)所以我们只需要比较这两个方向的最小值,加上当前位置的值即可。) - 状态数:O(n^2)
- 状态转移消耗:O(1)
- 时间复杂度:O(n^2)
java
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
int minsum = 0;
dp[0][0] = triangle[0][0];
for (int i = 1; i < triangle.size(); i++) {
for (int j = 0; j < triangle[i].size(); j++) {
if (j == 0)
dp[i][j] = dp[i - 1][j] + triangle[i][j];
else if (j == i)
dp[i][j] = dp[i - 1][j - 1] + triangle[i][j];
else
dp[i][j] =
min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle[i][j];
}
}
return *min_element(dp[n - 1].begin(), dp[n - 1].end());
}
};
5、股票系列
力扣有一些非常经典的股票问题,可以自己尝试去看一下。
121. 买卖股票的最佳时机这个解法不是dp
java
class Solution {
public:
int maxProfit(vector<int>& prices) {
int cost = INT_MAX, profit = 0;
for (int price : prices) {
cost = min(cost, price);
profit = max(profit, price - cost);
}
return profit;
}
};
你需要在每一天决定是否 买、卖、或不操作 股票,最终获得 最大利润 。
限制条件: 任何时候最多只能持有一股股票(即必须先卖出才能再买)。
我们每天的状态只有两种可能:
- **状态 0:**手里没有股票(可以买)
- **状态 1:**手里有股票(可以卖)
我们用一个二维数组 dp[i][0]
和 dp[i][1]
来记录第 i
天结束后,这两种状态下的 最大利润。
从第 i-1 天的状态推导第 i 天的状态
(1) 当前状态 0(手里没有股票)
- 可能来源:
- 昨天也没股票(今天没买),利润不变:
dp[i-1][0]
- 昨天有股票(今天卖了),利润 = 昨天有股票的利润 + 今天卖出的价格:
dp[i-1][1] + prices[i]
- 昨天也没股票(今天没买),利润不变:
- **取最大值:**dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
(2) 当前状态 1(手里有股票)
- 可能来源:
- 昨天也有股票(今天没卖),利润不变:
dp[i-1][1]
- 昨天没股票(今天买了),利润 = 昨天没股票的利润 - 今天买入的价格:
dp[i-1][0] - prices[i]
- 昨天也有股票(今天没卖),利润不变:
- **取最大值:**dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
4. 初始状态
- 第 0 天(第一天):
dp[0][0] = 0
(没有买,利润为 0)dp[0][1] = -prices[0]
(买了,但还没卖,利润为负)
5. 最终答案
最后一天 不持有股票的利润一定是最大的(因为持有股票还没卖的话,利润可能不是最大):return dp[n-1][0] // n 是天数
java
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2, 0));
// 初始状态
dp[0][0] = 0;
dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]);
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]);
}
return dp[n-1][0]; // 返回最后一天不持有股票的最大利润
}
};
java
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(4));
dp[0][0] = -prices[0], dp[0][1] = 0, dp[0][2] = -prices[0],
dp[0][3] = 0;
for (int i = 1; i < n; i++) {
dp[i][0] = max(-prices[i], dp[i - 1][0]);
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
dp[i][2] = max(dp[i - 1][1] - prices[i], dp[i - 1][2]);
dp[i][3] = max(dp[i - 1][2] + prices[i], dp[i - 1][3]);
}
return max(dp[n - 1][1], dp[n - 1][3]);
}
};
fucking-algorithm/动态规划系列/团灭股票问题.md at master · labuladong/fucking-algorithm
6、最短路径问题
Dijkstra 本质也是一个动态规划问题。只不过通过不断更新状态,来实现状态转移。
7、背包问题
++0/1背包DP++
++完全背包DP++
三、细节剖析
1、问题求什么,状态就尽量定义成什么,有了状态,再去尽力套状态转移方程。
2、动态规划的时间复杂度等于 状态数 x 状态转移 的消耗;
3、状态转移方程中的 i 变量导致数组下标越界,从而可以确定哪些状态是初始状态;
4、状态转移的过程一定是单向的,把每个状态理解成一个结点,状态转移理解成边,动态规划的求解就是在一个有向无环图上进行递推计算。
5、因为动态规划的状态图是一个有向无环图,所以一般会和拓扑排序联系起来。
题目
0、自然语言视频题解
3、C++视频题解
++使用最小花费爬楼梯++
++打家劫舍++
++删除并获得点数++
递推
剑指 Offer II 003. 前 n 个数字二进制中 1 的个数