[算法学习笔记] 0基础带你入门dp
前置知识
在学习dp前,你可能需要掌握:
- dfs
- 搜索及剪枝技巧
- 记忆化搜索
Advice:打开目录食用效果更佳
引子
我们从一个经典例题开始入门:
一只青蛙跳楼梯,一次可以一级或两级,若青蛙想要跳 \(n\) 级楼梯,有多少种跳法?
我们可以很容易想到暴力dfs,每次dfs跳一级和跳两级,跳到\(n\) 级后ans++
即可
暴力代码如下:
void dfs(long long int p)
{
if(p<0) return;
if(p==0) {ans++;return;}
dfs(p-2);
dfs(p-1);
}
但是这样复杂度很高,会T飞,为什么呢?举个例子看看!
假设青蛙想要跳10级楼梯,我们来把递归树画一下
(其实还没画完,但到这里已经非常明了)
我们发现,跳10级楼梯,要么先跳到9再跳1,要么先跳到8再跳两级;
同理,跳9级楼梯,要么先跳到8级再跳1级,要么先跳到7级再跳两级
......
是不是进行了很多重复的运算?怎么办!
我们可以每次dfs时记录一下跳当前楼梯数方案数,以后需要的时候直接调用,就没必要再算啦
这就是所谓记忆化搜索
参考代码如下:
int dfs(long long int p)
{
if(p==1 || !p) {return 1;}
if(rem[p]) return rem[p]; //如果当前点搜过直接用
else
{
rem[p] = dfs(p-1)+dfs(p-2); //否则跳p级楼梯的方案数=跳p-1方案数+跳p-2方案数
return rem[p];
}
}
最后输出rem[n]即可
这样,虽然是递归,但是递归树已经被我们砍成树干啦,复杂度大大降低。
大家应该看到了,上述记忆化搜索我们用到了这样的一行:
rem[p]=dfs(p-1)+dfs(p-2)
这是啥呢?通俗一点,就是:
跳p阶楼梯的方案数 = 跳p-1阶楼梯方案数+跳p-2阶楼梯方案数
如果不是很理解,我这里再把刚才的递归树放一下:
简单理解一下,就是:
- 如果想跳到10阶,要么先跳到9再跳1,要么先跳到8再跳2
- 如果想跳到9,要么先跳到8再跳1,要么先跳到7再跳2
......
我们发现,跳10阶楼梯和跳9阶跳8阶有关系,跳9阶楼梯和跳8阶跳7阶有关系......跳\(n\)阶楼梯和跳\(n-1\)阶跳\(n-2\)阶有关系,有什么关系呢?若\(f[n]\)表示跳\(n\)阶楼梯方案数,则满足:
\(f[n] = f[n-1]+f[n-2]\)
这玩意就在动态规划问题中就叫做状态转移方程,\(f_1,f_2......f_n\)就叫做最优子结构
我们是怎么推出这个式子来的?
回想一下,拿到这个问题,我们考虑了暴力dfs,然后进一步优化通过记忆化搜索,再然后我们发现了跳的楼梯间方案数之间的关系,得出了这个递推式。
其实动态规划可以理解为逆向的记忆化搜索,观察一下,我们在记忆化搜索的过程中是自上而下dfs,不断记录,然后下次搜索直接利用。而动态规划是自底向上去解决问题。
动态规划在递推前还需要确定边界,例如本题跳0阶和跳1阶的方案数都是1,则\(f_0=1;f_1=1\)
至此,我们得出了本题的动态规划方法,代码如下:
f[0]=1;f[1]=1;
for(int i=2;i<=n;i++) f[i] = f[i-1]+f[i-2];
cout<<dp[n]<<endl;
return 0;
What is DP
通过上述典例,我们可以简单总结一下动态规划问题处理的步骤,可能每个人的处理方法都不同,这里简单介绍一下我的思路。
首先,我们是不是得确定这是一个dp问题?回想一下跳楼梯:
- 搜索过程有大量重复
- 后面的选择对前面的选择无影响
对于第二条,有一个专有名词,叫做无后效性,这样的特性决定了我们的局部最优解是可以被利用的。
那么如何解决dp问题呢?
我自己通常是:
- 确立最优子结构
- 找到最优子结构之间的关系
- 确立边界,写出状态转移方程
dp的理论知识相对而言不是那么重要,接下来将通过几个简单dp实例带你更好理解dp。
DP实战
Part 1 最长不下降子序列
我们先来看一个比较典型的例题:最长不下降子序列
给定一个长度为 \(n\)的序列,试找到它的最长不下降子序列。
样例输入: 5
1 2 3 2 3
样例输出:4
首先,我们发现,后面的选择对前面肯定没有影响,满足无后效性,可以尝试dp
最优子结构如何确立呢?
我们尝试来一个一个看数组中的数:
- 当数组只有1时,最长不下降子序列是1,长度为1
- 当数组添加2时,最长不下降子序列是1,2;长度为2
- 当数组添加3时,最长不下降子序列是1,2,3;长度为3
- 当数组添加2时,最长不下降子序列是1,2,3;或1,2,2;长度为3
- 当数组添加3时,最长不下降子序列是1,2,3,3;长度为4
故样例数组最长不下降子序列是4
发现了什么?这几个序列的开头一样,但是结尾不一定一样,最长不下降子序列不一定一样,故不能将序列开头定为最优子结构。
再观察一下末尾,发现我们发现末尾一样(位置相同)的序列,其最长不下降子序列一样,故可以将末尾定为最优子结构。
此时,我们可以确立最优子结构:\(f_i\)表示以\(i\)结尾的最长不下降子序列长度。
我们再来尝试找一下它们之间的关系。
再观察一下上述样例,是不是每新添加一个数其最长不下降子序列都和它前面数为结尾的最长不下降子序列进行了拼接?容易证明,如果不拼接最大,一定不是最长不下降子序列,就没有继续下去的必要了(类似于记忆化搜索))
至此,我们得到了状态转移方程:
\(f_i = max(f_i,f_j) [j<i]\)
Part 2:纸币问题1
再看一个题:
原题连接:Luogu P2842
某国有 \(n\) 种纸币,每种纸币面额为 \(a_i\) 并且有无限张,现在要凑出 \(w\) 的金额,试问最少用多少张纸币可以凑出来?
我们设\(f_i\)表示\(i\)元至少需要多少纸币。
显然\(f_0=0\)不需要纸币,每一种面值也不需要凑,只需要一张纸币。
接下来考虑面额其他面额之间的关系。
共有\(n\)种纸币,设每一张面额为\(m_i\),则对于每一面额\(k\),我们是不是可以先凑出\(k-m_i\)元,然后再用一张面值为\(m_i\)的纸币?至于具体选什么,取最小值就好啦!
这样我们就找到了最优子结构之间的关系。状态转移方程如下:
\(f_k= min(f_k,f_{k-m_i}+1)\)
代码这里折叠了,有需要的可以查看:
点我看代码呀
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#define N 100010
#define INF 0x3f3f3f3f
using namespace std;
int a[N],dp[N];
int n,w;
int main()
{
memset(dp,INF,sizeof(dp));
scanf("%d%d",&n,&w);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
dp[0] = 0;
// dp[1] = 1;
for(int i=1;i<=w;i++)
{
for(int j=1;j<=n;j++)
{
if(a[j] <= i)
{
dp[i] = min(dp[i],dp[i-a[j]]+1);
}
}
}
cout<<dp[w]<<endl;
return 0;
}
Part 3.1:纸币问题2
再来一个纸币问题:
原题链接:Luogu P2840
你有 \(n\) 种面额互不相同的纸币,第 \(i\) 种纸币的面额为 \(a_i\) 并且有无限张,现在你需要支付 \(w\) 的金额,求问有多少种方式可以支付面额 \(w\),答案对 \(10^9+7\) 取模。
注意在这里,同样的纸币组合如果支付顺序不同,会被视作不同的方式。例如支付 \(3\) 元,使用一张面值 \(1\) 的纸币和一张面值 \(2\) 的纸币会产生两种方式(\(1+2\) 和 \(2+1\))。
同上一题不同的是,上一题问你有至少需要多少纸币凑出来,这一问问你有多少种方式了!
同理,我们设\(f_i\)表示凑\(i\)元方案数。
先确定边界:\(f_0=1\)因为不需要纸币。
再看看最优子结构之间有没有关系。
因为是统计方案问题,我们可以借鉴一下数楼梯。
显然,若面额为\(m_i\),凑\(k\)元,则满足:
\(f_k = \sum\limits_{j=1}^n f_k-m_j\)
解释一下:
想要凑\(k\)元,我们是不是可以先选择凑出\(k-m_i\)元然后再用一张\(m_i\)的纸币。
和数楼梯同理,后面的继续递推即可。
同样代码折叠一下吧:
点我看代码呀
#include <iostream>
#include <cstdio>
#define ll int
#define N 10010
using namespace std;
ll n,w;
const int INF = 1e9+7;
ll minn =INF;
ll money[N],dp[N];
int main()
{
scanf("%d%d",&n,&w);
for(ll i=1;i<=n;i++)
{
scanf("%d",&money[i]);
minn=min(minn,money[i]);
}
dp[0] =1;
for(ll i=1;i<=w;i++)
{
for(ll j=1;j<=n;j++)
{
if(money[j]<=i) dp[i] = (dp[i]+dp[i-money[j]]),dp[i]%=INF;
}
}
cout<<(dp[w]%INF);
}
Part 3.2 纸币问题变形/纸币问题3
其实上述例题还有一个变形,上述例题特别指明:
注意在这里,同样的纸币组合如果支付顺序不同,会被视作不同的方式。例如支付 \(3\) 元,使用一张面值 \(1\) 的纸币和一张面值 \(2\) 的纸币会产生两种方式(\(1+2\) 和 \(2+1\))。
若不特别指明,类似于(1+2)和(2+1)算作一种方式呢?我们又如何去重呢?
不妨考虑每种纸币的贡献。
显然每种纸币只对大于其面额的钱起作用,我们可以先枚举每种钱,若求\(w\)元,当前钱面值为\(k\),则当前钱只对面额\(\geq k\)且\(\leq w\)起作用,只需要在这个区间内枚举即可。
为什么这样能去重呢?
我们来对比一下这两种纸币问题。
第一种纸币问题是类似于(1,2)(2,1)为不同的情况,我们是从1到\(w\)计算每种钱所需最少纸币数,由于我们是从小到达依次递推的,所以确保后面的计算对前面的调用是有效的,可以直接利用,这样,类似于(1,2)(2,1)就当作两种情况计算了。
而第二种纸币问题,我们是每输入一种面额就对\(1-w\)种大于当前面额的钱进行计算。显然此时对前面的调用不一定是有效的,还是举(1,2)(2,1)的例子,我们在添加1的纸币时,还不知道有2,但尽可能的计算,后来又添加了2,由于前面已经计算了1相关,故算作一种情况。
如果很抽象自行理解一下吧(笔者语文不好ww)
代码已折叠,自行取用。
点我取代码捏
#include <iostream>
#include <cstdio>
#include <algorithm>
#define N 10010
using namespace std;
const int mod=1e9+7;
int dp[N];
int n,w;
int main()
{
dp[0] = 1;
scanf("%d%d",&n,&w);
for(int i=1;i<=n;i++)
{
int k;
scanf("%d",&k);
for(int j=k;j<=w;j++) dp[j]=(dp[j]+dp[j-k])%mod;
}
cout<<dp[w]<<endl;
return 0;
}
Part 4 线性dp基础&记录选择:挖地雷
原题链接:Luogu
在一个地图上有 \(N\ (N \le 20)\) 个地窖,每个地窖中埋有一定数量的地雷。同时,给出地窖之间的连接路径。当地窖及其连接的数据给出之后,某人可以从任一处开始挖地雷,然后可以沿着指出的连接往下挖(仅能选择一条路径),当无连接时挖地雷工作结束。设计一个挖地雷的方案,使某人能挖到最多的地雷。
本题我写过题解,这里不再赘述。题解在这里呀
Part 5 基础图上dp:滑雪
原题链接:Luogu P1434
同理,写过题解,这里不再赘述。题解在这里呀
Part 6 简单的只有相邻操作的dp 最大子段和
原题链接:Luogu P1115
给出一个长度为\(n\)的序列\(a\),选取连续的非空的一段使这段和最大。
发现我们只能对相邻的数字进行操作。
借鉴最长不下降子序列,我们设\(f_i\)表示以\(i\)结尾的连续子段和最大。
显然因为必须连续,我们\(f_i\)可以选择和\(f_{i-1}\)拼,也可以选择不拼,取max就好啦!
至于边界,显然初始化\(f_i = a_i\)。
代码折叠,自行取食。
代码在这里呀
#include <iostream>
#include <cstdio>
#include <algorithm>
#define N 1001001
#define INF 1e9
#define int long long
using namespace std;
int dp[N];
int a[N];
int n;
int maxn = -INF;
signed main()
{
// freopen("input.txt","r",stdin);
scanf("%lld",&n);
for(int i=1;i<=n;i++) scanf("%lld",&a[i]),dp[i] = a[i];
for(int i=2;i<=n;i++)
{
dp[i] = max(dp[i],dp[i]+dp[i-1]);
// cout<<dp[i]<<endl;
maxn = max(maxn,dp[i]);
}
cout<<maxn<<endl;
return 0;
}
小结
相信大家也看出来了,本文绝大多数都在将dp例题,对于dp理论一笔带过。
要想熟练掌握dp,一定要多加练习,多做题,多总结,这样再做其他题就都是套路啦!
本文可能继续更,也可能咕咕咕
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/17548848.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!