动态规划,我们简称为DP,是一种用来解决重叠子问题和最优子结构性质的算法。
核心思想是我们把一个大问题,分解成若干个子问题,然后计算并存下子问题的解,减少重复计算的步骤,来提高效率,而减少重复计算的步骤最关键的点就是能否找到一个公共的式子去计算子问题,也就是我们的状态转移方程
动态规划的性质
①最优子结构
②无后效性
动态规划的要素
①状态的设计
②状态的转移方程
③边界条件
①状态的设计
状态的设计,就是开一个dp数组,然后赋予这个dp数组独特的意义,在斐波那契数列中,dp[i]表示斐波那契数列的第i项的答案
对于绝大多数的动态规划来说,通常我们要求什么,就把dp数组的意义设置成什么
②状态的转移方程
当设计好一个状态之后,不同的状态之间应该存在一些特殊的关系
转移是指,在事件进行的过程中,我们能从哪个状态抵达哪个状态,比如斐波那契数列中,如果我们想得到第3项的值,那么我们就要根据第一项和第二项的值算出来,也就是说,为了得到3的具体状态,我们需要从1和2的状态计算出来,根据某些状态计算出其他状态的过程叫转移
而转移方程就是转移的规则,就是当前项要如何通过其他项计算出来,找出一个规律列出式子,就是状态转移方程
③边界条件
边界条件指的是,我们可以直接根据题目的限制,无需计算,直接就能知道的dp值,通常这些dp值会作为整个动态规划的起点
例题:
1.支付问题
现在有1,5,11三种货币
现在想用这三种货币凑出x元,最少需要使用多少张货币。
假设我们想到贪心,每次都先用面额最大的来凑,不够了就换更小的面额
但是如果x=15,按照贪心算法,会拿11+1+1+1+1总共5张货币,但是最优解显然是5+5+5三张
所以我们需要使用动态规划,假设我们拿完一张钱后,总钱数变成了x,那么在拿这张钱之前,我们可能有x-1,x-5,x-11元
思路:
设dp【i】的意义是凑出i元需要的最少张数
状态转移方程:dp【i】 = min(dp【i-1】,dp【i-5】,dp【i-11】)+1
边界条件:dp【0】 = 0,凑出0元,使用了0张
cpp
#include<iostream>
#include<limits.h>
using namespace std;
int dp[1005];
int main() {
int n;
cin >> n;
for (int i = 1;i <= n;i++) {
int mi = INT_MAX;
//考虑是从dp【i-1】转移还是dp[i-5]还是dp[i-11]
if (i >= 1)mi = min(mi, dp[i - 1]);
if (i >= 5)mi = min(mi, dp[i - 5]);
if (i >= 11)mi = min(mi, dp[i - 11]);
dp[i] = mi + 1;
cout << "i:" << i << " dp[i]:" << dp[i] << endl;
}
return 0;
}
在状态转移方程中的取最小值的操作,其实体现了动态规划和递推的本质区别。动态规划在转移的过程中,是要做出最优的决策的,每一步的转移都可能有多个选项,而我们的任务是找出一个最好的选项。
而递推只有一个式子,我们只能按照这个式子去计算,在这个过程中,我们没有办法做出任何能够影响答案的决策,例如斐波那契数列问题,过程中没有做出任何决策,所以只能被称为递推
2.最少体力
假设我们有一个n*m的网格,我们一开始站在最左上角(1,1),每次我们可以走一步,我们规定,每一步要么向下,要么向右,最后一定会走到(n,m)。
每个网格都有一个值a(i,j),意义是我们走到(i,j)这个网格需要花费的体力。
问从左上角走到右下角最少体力花费是多少
思路:
假设我们现在在(i,j)这个位置,那么上一步只有两种可能,要么从当前格子的上面向下走一步到达当前位置,要么从左边向右走了一步到达这个位置
于是我们可以设计dp[i][j]的意义是,从起点(1,1)走到(i,j)这个位置最少的体力花费
状态转移方程:dp[i][j] = min(dp[i-1][j] , dp[i][j-1]) + a[i][j]。
边界条件:dp[1][1] = 0。同时,这个题存在一些特殊的边界,考虑第一行和第一列,第一行无法从上面转移,第一列无法从左边转移,所以我们需要先处理这些特殊数据
cpp
#include<iostream>
using namespace std;
int a[1005][1005];
int dp[1005][1005];
int main() {
int n, m;
cin >> n >> m;
for (int i = 1;i <= n;i++) {
for (int j = 1;j <= m;j++) {
cin >> a[i][j];
}
}
//初始化边界条件
dp[1][1] = a[1][1];
for (int i = 2;i <= n;i++) {
dp[i][1] = dp[i - 1][1] + a[i][1];
}
for (int i = 2;i <= m;i++) {
dp[1][i] = dp[1][i - 1] + a[1][i];
}
//动态规划,状态转移方程
for (int i = 2;i <= n;i++) {
for (int j = 2;j <= m;j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + a[i][j];
}
}
//得到结果
cout << dp[n][m] << endl;
return 0;
}
3.一本通1258数字金字塔
首先明确是如何转移状态的,对于每一个位置,要么从位置的上面转移过来,要么从这个位置的左上角转移过来
思路:
边界条件:dp[1][1] = a[1][1]
状态转移方程:可以从起点开始往下搜索,最后遍历一遍dp数组最后一行找最大值,也可以从最下面一行开始往上搜索,可以减去一次遍历操作
cpp
#include<iostream>
using namespace std;
int a[1005][1005];
int dp[1005][1005];
int main() {
int n;
cin >> n;
for (int i = 1;i <= n;i++) {
for (int j = 1;j <= i;j++) {
cin >> a[i][j];
}
}
dp[1][1] = a[1][1];
for (int i = 2;i <= n;i++) {
dp[i][1] = dp[i - 1][1] + a[i][1];
}
for (int i = 2;i <= n;i++) {
dp[i][i] = dp[i - 1][i - 1] + a[i][i];
}
for (int i = 3;i <= n;i++) {
for (int j = 2;j < i;j++) {
//找最大值
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + a[i][j];
}
}
//最后遍历最后一行找最大值
int ans = 0;
for (int i = 1;i <= n;i++) {
ans = max(ans, dp[n][i]);
}
cout << ans << endl;
return 0;
}
也可以从下往上搜索,减少一次遍历找最大值的过程,但是要注意循环的起始点
cpp
#include<iostream>
using namespace std;
int dp[1005][1005];
int a[1005][1005];
int main() {
int n;
cin >> n;
for (int i = 1;i <= n;i++) {
for (int j = 1;j <= i;j++) {
cin >> a[i][j];
}
}
//只需要把dp数组最后一行赋值就可以了
for (int i = 1;i <= n;i++) {
dp[n][i] = a[n][i];
}
//从终点往起点,从下往上搜索
for (int i = n - 1;i > 0;i--) {
for (int j = 1;j <= i;j++) {
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j];
}
}
//最后起点的位置就是我们要的答案
cout << dp[1][1] << endl;
return 0;
}
总结
一、动态规划的核心思想
核心 :将大问题分解为重叠子问题 ,通过状态转移避免重复计算,用空间换时间。
三要素:
- 状态:定义 dp[i] 或 dp[i][j] 表示什么?
- 转移方程:当前状态如何由之前状态推导?
- 初始化:最小子问题的答案(边界条件)。
二、经典解题框架(5步法)
-
确定 dp 含义(最关键,决定正确性)
-
找到状态转移方程(分析最后一步或相邻状态关系)
-
初始化 base case(边界条件,如 dp[0]、dp[1])
-
确定遍历顺序(正序/逆序,一维/二维)
-
举例验证(小规模手动推导)