文章目录
1.动态规划DP
(1)题目特点
dp的无后效性。
PS:上面的问题是“多少种”,如果题目是要求所有方案分别为什么就不能用dp。
DP是一种算法设计技巧,其固定流程:递归的暴力解法–>带备忘录的递归解法–>非递归的动态规划解法。过程层层递进。
I.斐波那契数列
(1)暴力的递归解法
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
(2)带备忘录的递归解法(自顶向下)
也即进行了剪枝的递归:
int F(int n){
if(n==0||n==1) return 1;//递归边界
if(dp[n]!=-1) return dp[n];
else{
dp[n]=F(n-1)+F(n-2);//计算F(n),并保存到dp[i]中
return dp[n];
}
}
一般使用数组充当备忘录(也可用哈希表/字典),时间复杂度是O(n)。
(3)动态规划(自底向上)
int fib(int N) {
vector<int> dp(N + 1, 0);
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[N];
}
不用一个DP数组,只需要用2个变量,空间复杂度为O(1),其实这种做法也即后面二维数组降到一维数组的【滚动数组】做法,能够降低空间复杂度。
状态转移方程, 如斐波那契数列的f(n)想成一个状态n——这个状态n即是由状态n-1和状态n-2相加转移而来,即状态转移。
dp性质:(1)重叠子问题;(2)最优子结构;(3)状态转移方程:明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。
重叠子问题:联想递归,很多重复计算的分支。
关于子问题,如下的青蛙跳阶梯问题,从最后一步出发,推出递归公式:
最优子结构:原问题的解由子问题的最优解构成,如1、2、5面值硬币拼成n=11元时,求最少需要多少枚硬币拼成11,而f(11)由f(10)、f(9)、f(6)的最优解转移而来。
II.拼硬币
思考如何列出正确的状态转移方程。
第一,先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额amount。
第二,确定dp函数的定义:函数 dp(n)表示,当前的目标金额是n,至少需要dp(n)个硬币凑出该金额。
第三,确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当的目标金额是多少,选择就是从面额列表coins中选择一个硬币,然后目标金额就会减少:
# 伪码框架
def coinChange(coins: List[int], amount: int):
# 定义:要凑出金额 n,至少要 dp(n) 个硬币
def dp(n):
# 做选择,需要硬币最少的那个结果就是答案
for coin in coins:
res = min(res, 1 + dp(n - coin))
return res
# 我们要求目标金额是 amount
return dp(amount)
第四,明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:
(1)暴力解法
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = coinChange(coins, amount - coin);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
return ans == INT_MAX ? -1 : ans;
}
(2)带备忘录的递归
通过备忘录消除子问题:
int coinChange(vector<int>& coins, int amount) {
// 备忘录初始化为 -2
vector<int> memo(amount + 1, -2);
return helper(coins, amount, memo);
}
int helper(vector<int>& coins, int amount, vector<int>& memo) {
if (amount == 0) return 0;
if (memo[amount] != -2) return memo[amount];
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = helper(coins, amount - coin, memo);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
// 记录本轮答案
memo[amount] = (ans == INT_MAX) ? -1 : ans;
return memo[amount];
}
(3)动态规划
用自底向上方法使用dp table消除重叠子问题:
dp[i] = x
表示,当目标金额为i时,至少需要x枚硬币。
PS:为啥dp数组初始化为amount + 1
呢,因为凑成amount金额的硬币数最多只可能等于amount(全用 1 元面值的硬币),所以初始化为amount + 1
就相当于初始化为正无穷,便于后续取最小值。
int coinChange(vector<int>& coins, int amount) {
// 数组大小为 amount + 1,初始值也为 amount + 1
vector<int> dp(amount + 1, amount + 1);
// base case
dp[0] = 0;
for (int i = 0; i < dp.size(); i++) {
// 内层 for 在求所有子问题 + 1 的最小值
for (int coin : coins) {
// 子问题无解,跳过
if (i - coin < 0) continue;
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
(1)最大连续子列和总结
(2)
2.DP解题步骤
三种硬币,分别面值为2、5、7元(每种硬币都有足够多),需要买一本书27元,求如何用最少的硬币组合正好付清(不需要对方找钱)。
(1)确定状态
开一个数组,数组的每个元素f[i]
或者f[i][j]
代表什么;
确定状态需要2个意识:1)最后一步;2)子问题。
(2)转移方程
(3)初始条件+边界情况
初始条件即用转移方程算不出来(需要手工定义)。
边界:不要数组越界。
(4)计算顺序
小结
3.背包问题
在DFS&剪枝中提到过这个背景:
有n件物品,每件物品的重量为w[i],价值为c[i],现有一容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大,其中每种物品都只有一种。
样例:
5 8 //n=5 ,V=8
3 5 1 2 2 //w[i]
4 5 2 1 3//v[i]
如果按照之前的暴力枚举递归做法(每种物品有放或不放两种选择,然后直接使用递归则时间复杂度为O(2^n)),现用dp降低为O(nV)。
(1)确定状态:
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]表示前i件物品放入容量为v的背包中可获得的最大价值。
1)若不选第i件物品,则现求前i-1件物品敲好转入容量为v的背包中能获得的最大价值,dp[i-1][v]
。
2)若选第i件物品,则现求前i-1件物品恰好装入容量为v-w[i]
的背包中所能获得的最大价值,即dp[i-1][v-w[i]]+c[i]
。
(2)转移方程
根据这两种决策即 d p [ i ] [ v ] dp[i][v] dp[i][v]为1)和2)中的最大值,即状态转移方程:
m a x { d p [ i − 1 ] [ v ] , d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] } max\{dp[i-1][v],dp[i-1][v-w[i]]+c[i] \} max{dp[i−1][v],dp[i−1][v−w[i]]+c[i]} ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) (1≤i≤n,w[i]≤v≤V) (1≤i≤n,w[i]≤v≤V)
(3)递归边界+初始条件
递归边界 d p [ 0 ] [ v ] = 0 ( 0 ≤ v ≤ V ) dp[0][v]=0(0≤v≤V) dp[0][v]=0(0≤v≤V)即前0件物品放入任何容量v的背包中都只能获得价值0。
for(int i=1;i<=n;i++){
for(int v=w[i];v<=V;v++){
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i]);
}
}
(4)计算顺序
dp[i][v]
只与之前的状态dp[i-1][]
有关,所以i从1得到n枚举,v从0到V枚举。
PS:滚动数组
求
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]时总是只 需要
d
p
[
i
−
1
]
[
v
]
dp[i-1][v]
dp[i−1][v]部分的数据(下图阴影部分),即当计算
d
p
[
i
+
1
]
[
]
dp[i+1][]
dp[i+1][]时,只用到
d
p
[
i
]
[
]
dp[i][]
dp[i][]而用不到
d
p
[
i
−
1
]
[
]
dp[i-1][]
dp[i−1][]的除了
d
p
[
i
−
1
]
[
v
]
dp[i-1][v]
dp[i−1][v]的部分——所以直接开一维数组dp[v]即可,
(1)现在的计算顺序:将i从1到n枚举,v从V到w[i]逆序枚举。
原来的转移方程
d
p
[
i
]
[
v
]
=
m
a
x
{
d
p
[
i
−
1
]
[
v
]
,
d
p
[
i
−
1
]
[
v
−
w
[
i
]
]
+
c
[
i
]
}
dp[i][v]=max\{dp[i-1][v],dp[i-1][v-w[i]]+c[i] \}
dp[i][v]=max{dp[i−1][v],dp[i−1][v−w[i]]+c[i]}
( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) (1≤i≤n,w[i]≤v≤V) (1≤i≤n,w[i]≤v≤V)
改为一维的转移方程
d
p
[
v
]
=
m
a
x
{
d
p
[
v
]
,
d
p
[
v
−
w
[
i
]
]
+
c
[
i
]
}
dp[v]=max\{dp[v],dp[v-w[i]]+c[i] \}
dp[v]=max{dp[v],dp[v−w[i]]+c[i]}
(
i
≤
i
≤
n
,
w
[
i
]
≤
v
≤
V
)
(i≤i≤n,w[i]≤v≤V)
(i≤i≤n,w[i]≤v≤V)
(2)现在的计算顺序
v从右往左,
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]右边的部分为刚计算过的需要保存给下一行使用的数据,
而
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]左上角的阴影部分为当前需要使用的部分。
若把dp[i][v]左上角和右边的部分放在一个数组里,每计算出一个dp[i][v],相当于把dp[i-1][v]舍去(因后面用不到了)。
for(int i=1;i<=n;i++){
for(int v=V;v>=w[i];v--){
dp[v]=max(dp[v],dp[v-w[i]]+c[i]);
}
}
01背包问题用一位数组解决,空间复杂度为O(V)。
4.数塔问题
算法4是逆推法(外层for循环是从大到小遍历,和算法3不一样)。
动态规划系列专题讲义
专题一:数塔问题
/*
Name: 动态规划专题之数塔问题
Author: 巧若拙
Description:7625_三角形最佳路径问题
描述:如下所示的由正整数数字构成的三角形:
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
从三角形的顶部到底部有很多条不同的路径。对于每条路径,把路径上面的数加起来可以得到一个和,和最大的路径称为最佳路径。你的任务就是求出最佳路径上的数字之和。
注意:路径上的每一步只能从一个数走到下一层上和它最近的下边(正下方)的数或者右边(右下方)的数
输入
第一行为三角形高度100>=h>=1,同时也是最底层边的数字的数目。
从第二行开始,每行为三角形相应行的数字,中间用空格分隔。
输出
最佳路径的长度数值。
样例输入
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
样例输出
30
*/
#include<iostream>
#include<cstring>
using namespace std;
const int MAX = 100;
int map[MAX][MAX];
int B1[MAX][MAX]; //备忘录,记录从位置(x,y)到达底行所获得的最大值
int B2[MAX][MAX]; //备忘录,记录从顶点到位置(x,y)所获得的最大值
int bestP; //记录最优解
void DFS(int n, int x, int y, int curP);//n表示行数,x,y表示当前位置坐标,curP表示目前已走路径上的权值和
int DP_1(int n, int x, int y);//n表示行数,计算从位置(x,y)到达底行所获得的最大值
int DP_2(int n); //动态规划;顺推法
int DP_3(int n); //动态规划(逆推法)
int main()
{
int n;
cin >> n;
for (int i=0; i<n; i++)
{
for (int j=0; j<=i; j++)
{
cin >> map[i][j];
}
}
//回溯算法
DFS(n, 0, 0, map[0][0]);
cout << bestP << endl;
//记忆化搜索(备忘录算法)
memset(B1, -1, sizeof(B1)); //先初始化B1的值全为-1
cout << DP_1(n, 0, 0) << endl;
//动态规划(顺推法)
cout << DP_2(n) << endl;
//动态规划(逆推法)
cout << DP_3(n) << endl;
return 0;
}
算法1:回溯算法,需要用到全局变量map[MAX][MAX],另有bestP初始化为0。
void DFS(int n, int x, int y, int curP)//n表示行数,x,y表示当前位置坐标,curP表示目前已走路径上的权值和
{
if (x == n-1) //语句1
{
if (curP > bestP)
bestP = //语句2
}
else
{
DFS(n, x+1, y, curP+map[x+1][y]); //向正下方走
DFS( ); //语句3
}
}
问题1:能否把语句1改为if (x == n)?为什么?
问题2:将语句2和语句3补充完整。
参考答案:
问题1:不能修改,因为递归出口是x == n-1,因为数组的下标是从0开始的,(n-1)表示第n行(底行)。
问题2:语句2:bestP = curP;
语句3:DFS(n, x+1, y+1, curP+map[x+1][y+1]);
算法2:记忆化搜索(备忘录算法),需要用到全局变量map[MAX][],另有B1[MAX][]初始化为-1。
int DP_1(int n, int x, int y)//n表示行数,计算从位置(x,y)到达底行所获得的最大值
{
if (B1[x][y] != -1)
return //语句1
if (x == n-1)
B1[x][y] = //语句2
else
B1[x][y] = map[x][y] + max(DP_1(n,x+1,y), DP_1(n,x+1,y+1));
return B1[x][y];
}
问题1:将语句1和语句2补充完整。
问题2:与算法1(回溯算法)相比,算法2(备忘录算法)有哪些优越之处?
参考答案:
问题1:语句1:return B1[x][y];
语句2:B1[x][y] = map[x][y];
问题2:回溯算法进行了重复搜索,而备忘录算法利用二维数组B1[x][y]记录了已搜索路径,无需重复搜索,大大提高了效率。
算法3:动态规划(顺推法),需要用到全局变量map[MAX][],另有B2[MAX][]初始化为0。
int DP_2(int n)//动态规划;顺推法
{
B2[0][0] = map[0][0];
for (int i=1; i<n; i++)//语句1
B2[i][0] = map[i][0] + B2[i-1][0];
for (int i=1; i<n; i++)
{
for (int j=1; j<=i; j++) //语句2
{
B2[i][j] = map[i][j] + max(B2[i-1][j], B2[i-1][j-1]);
}
}
int s = B2[n-1][0];
for (int j=1; j<n; j++) //语句3
{
if (s < B2[n-1][j])
s = B2[n-1][j];
}
return s;
}
问题1:语句1所在循环体的作用是什么?为什么要单独列出来?能否合成在语句2中?
问题2:语句2能否改为:for (int j=i; j>0; j--) ?为什么?
问题3:语句3能否改为:for (int j=n-1; j>0; j--) ?为什么?
参考答案:
问题1:语句1所在循环体的作用是为第一列赋值。单独列出来的原因是第一列的元素已经处于最左侧,其左上方无元素,故其值直接由其正上方元素决定;而语句2中的元素值由其左上方和正上方元素的较大值来决定。如果把语句1中的循环体并入语句2中,则需要判断其下标是否越界,降低了效率。
问题2:可以。因为算法3是从上往下推的顺推法,其状态方程为:B2[i][j] = map[i][j] + max(B2[i-1][j], B2[i-1][j-1]);是利用上一行元素来计算下一行元素的值,与本行元素无关,故列坐标j递增或递减均可。
问题3:可以。因为语句2所在循环体的作用是在底行寻找最大值,顺序或逆序扫描数组B2[n-1][j]均可,故列坐标j递增或递减均可。
算法4:动态规划(逆推法),需要用到全局变量map[MAX][]。
int DP_3(int n) //动态规划(逆推法)
{
for (int i=n-2; i>=0; i--) //语句1
{
for (int j=i; j>=0; j--)
{
map[i][j] += max(map[i+1][j], map[i+1][j+1]);
}
}
return map[0][0];
}
问题1:语句1能否改为:for (int i=n; i>0; i--) ?为什么?
问题2:与算法3(顺推法)相比,算法4(逆推法)有哪些优越之处?
参考答案:
问题1:不能。因为算法4是从右下角斜向上走,最终到达顶点的逆推法,它直接利用数字三角形的特征,从倒数第2行的最右列元素开始,直接把其正下方和右下方元素中的较大值累加到自身,这样在处理左上角元素的时候,刚好得到最优解。
语句1是外层循环,从下往上遍历各行,故循环变量i需要递减。
问题2:主要优点有二。一是顺推法得到所有的解后,需要遍历底行找出最大值;而逆推法直接得出最大值,效率更高。
二是顺推法需要先求出第一列的值,然后根据状态方程求出其他元素的值;而逆推法可以直接利用递推式计算出所有元素值,代码相对更简洁。
拓展练习:原题只要求算出最佳路径上的数字之和,并未要求输出最佳路径。现在要求在算法4 int DP_3(int n)的基础上,编写函数void PrintPath(int n);//输出最佳路径。
参考答案:
void PrintPath(int n) //输出最佳路径
{
int i = 0, j = 0;
for (int k=1; k<n; k++) //输出前n-1行
{
if (map[i+1][j] > map[i+1][j+1]) //向正下方走
{
cout << map[i][j] - map[i+1][j] << "->";
i++;
}
else //向右下方走
{
cout << map[i][j] - map[i+1][j+1] << "->";
i++; j++;
}
}
//输出底层元素
cout << map[i][j] << endl;
}
5.最大回文子串
状态转移方程:
d
p
[
i
]
[
j
]
=
dp[i][j]=
dp[i][j]=
d
p
[
i
+
1
]
[
j
−
1
]
,
S
[
i
]
=
S
[
j
]
dp[i+1][j-1],S[i]=S[j]
dp[i+1][j−1],S[i]=S[j]
0
,
S
[
i
]
!
=
S
[
j
]
0,S[i]!=S[j]
0,S[i]!=S[j]
如果按照i和j从小到大的顺序枚举子串的两个端点,然后更新
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j],会无法保证
d
p
[
i
+
1
]
[
i
−
1
]
dp[i+1][i-1]
dp[i+1][i−1]已经被计算过(从而无法得到正确的
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j])。
总结
(1)01背包中每件物品都可看做一个阶段,该阶段有dp[i][0]~dp[i][V]——均由上一个阶段的状态得到。
对能够划分阶段的问题来说,都可尝试把阶段作为状态的一维。
(2)如果当前设计的状态不满足【无后效性】,则可以增加一维或若干维来表示相应的信息,以满足无后效性。
背包问题从二维数组转为一维数组——直接看最后2段即可。
参考资料
【1】labuladong公众号
【2】https://www.cnblogs.com/kkbill/p/12081172.html
【3】数塔问题https://blog.csdn.net/QiaoRuoZhuo/article/details/76849960?locationNum=8&fps=1
【4】侯卫东上课dp笔记