动态规划
其本质是暴力解法的优化,使用空间换时间的方式,存储迭代过程中的过程数据。
比较简单和经典的案例就是斐波那契数列的实现。
public int fb(int n){
if(n<=2){
return n;
}
int n1=1;
int n2=2;
int res=0;
for(int i=3;i<=n;i++){
res=n1+n2;
n1=n2;
n2=res;
}
return res;
}
斐波那契的基本公式:
f(n)=f(n−1)+f(n−2)
f(n)=f(n-1)+f(n-2)
f(n)=f(n−1)+f(n−2)
可以对应出动态方程:
dp(i)={1i∈[0,1]dp(i−1)+dp(i−2)i>=2
dp(i)=\begin{cases}
1&i\in[0,1]
\\dp(i-1)+dp(i-2)&i>=2
\end{cases}
dp(i)={1dp(i−1)+dp(i−2)i∈[0,1]i>=2
这里包括了动态改变的定义还包括了两个基础值。
从这个例子中其实就可以看到动态规划的两个基本要素
- 状态转移方程,定义状态的改变
- base case,定义边界。
经典案例
通过几个经典案例来分析动态规划具体是怎么操作的。
<例子一>零钱问题
“给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1”
可以来具体分析下:比如要你求出amount=11是最少的硬币数,如果你知道amount=10时最少的硬币数,那你只要在这个基础上加一个硬币就可以了。而且由于硬币没有数量限制,这个公式肯定是可以成立的。
- 1.先确定这个过程中变化的量,只有amount发生了变化
- 2.确定dp函数:当前金额是n那需要的硬币就是dp(n)
dp(n)={0:n=0−1:n<0min{dp(n−coin)+1∣coin∈coins}:n>0 dp(n)=\begin{cases} 0&:n=0\\ -1&:n<0\\ min\{dp(n-coin)+1|coin\in coins\}&:n>0 \end{cases} dp(n)=⎩⎪⎨⎪⎧0−1min{dp(n−coin)+1∣coin∈coins}:n=0:n<0:n>0
具体的代码:
public int changes(int[] coins,int amount){
if(amount==0){
return 0;
}
int[] dp=new int[amount+1];
Arrays.fill(dp,amount+1);
dp[0]=0;
for(int i=1;i<=amount;i++){
for(int coin:coins){
if(i>=coin){
dp[i]=Math.min(dp[i],(1+dp[i-coin]));
}
}
}
return dp[amount]>=amount+1?-1:dp[amount];
}
动态规划涉及几个关键性的问题
- 最优自结构
- 遍历方式,正遍历,倒遍历,斜遍历
- 状态转移方程
- base case
遍历顺序
- 遍历的过程中,所需的状态必须是已经计算出来的
- 遍历的终点必须是存储结果的那个位置
到这里其实可以得出一个核心:动态规划的核心是数学归纳法
<例子二>背包问题
给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价值最高。
背包问题可以引申为八种问题
- 0/1背包问题
- 完全背包问题
- 多重背包问题
- 混合三种背包问题
- 二维费用背包问题
- 分组背包问题
- 有依赖背包问题
- 求背包问题的方案总数
0/1背包问题
每个物品只可以拿一次
定义状态转移函数dp(i,j)
思考数学归纳问题,选择第i件物品是否放入背包导致两种情况
- 不放入背包则整个价值还是前一个物品放入时的价值
- 放入背包则价值变为这个物品的价值与扣取这个物品重量的前一个物品放入价值的和
注意这里面容易出错的问题:这是出现比较,前提是物体可以被放入,如果物体根本不能被放入则价值应该等于前一个物品被放入的价值。
动态转移方程可以归纳为
dp(i,j)={dp(i−1,j)w(i)>jmax(dp(i−1,j)∣dp(i−1,j−w(i))+w(i))w(i)<=j
dp(i,j)=\begin{cases}
dp(i-1,j)&w(i)>j
\\max(dp(i-1,j)|dp(i-1,j-w(i))+w(i))&w(i)<=j
\end{cases}
dp(i,j)={dp(i−1,j)max(dp(i−1,j)∣dp(i−1,j−w(i))+w(i))w(i)>jw(i)<=j
注意函数最终在实现过程中的下标问题
我们习惯于dp结果都从重量和物品的非零处开始处理,但是这样实际物体的下标就会不同,需要减1.
完整程序:
public int knapSack(int[] w,int[] v,int bag){
int n=w.length;
if(n==0){
return 0;
}
int[][] dp=new int[n+1][bag+1];
for(int i=1;i<=n;i++){
for(int j=1;j<=bag;j++){
if(w[i-1]<=j){
dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-w[i-1]]+v[i-1]);
}else{
dp[i][j]=dp[i-1][j];
}
}
}
return dp[n][bag];
}
从观察中我们可以看到其实可以不需要二维数组,可以将二维数组压缩为一维循环数组。每次迭代时存储前一个物品的价值就可以了
具体代码变化:
public int knappSack(int[] w,int[] v,int bag){
int n=w.length;
if(n==0){
return 0;
}
int[] dp=new int[bag+1];
for(int i=1;i<=n;i++){
for(int j=1;j<=bag;j++){
int temp=dp[j];
if(w[i-1]<=j){
dp[j]=Math.max(temp,dp[j-w[i-1]]+v[i-1]);
}
}
}
return dp[bag];
}
完全背包问题
不限制拿取物品数量,其实这个本质就很像零钱问题。
和0/1问题的主要区别就是每次可以选择重复物品。
数学递归表达式可以写为:
dp(i,j)=max{dp(i−1,j−k∗w[i])+k∗v[i]∣k∈[0,j]}
dp(i,j)=max\{dp(i-1,j-k*w[i])+k*v[i]|k\in[0,j]\}
dp(i,j)=max{dp(i−1,j−k∗w[i])+k∗v[i]∣k∈[0,j]}
可以直接利用0/1问题的空间优化方案,实际使用表达式可以改为
dp(j)=max{dp(j−k∗w[i])+k∗v[i]∣k∈[0,j]}
dp(j)=max\{dp(j-k*w[i])+k*v[i]|k\in[0,j]\}
dp(j)=max{dp(j−k∗w[i])+k∗v[i]∣k∈[0,j]}
完整代码:
public int knappSack(int[] w,int[] v,int bag){
int n=w.length;
if(n==0){
return 0;
}
int[] dp=new int[bag+1];
for(int i=1;i<=n;i++){
for(int j=1;j<=bag;j++){
int temp=dp[j];
for(int k=0;k<=j;k++){
if(k*w[i-1]<=j){
dp[j]=Math.max(temp,dp[j-k*w[i-1]]+k*v[i-1]);
}
}
}
}
return dp[bag];
}
<例子三>最长上升子序列
首先可以得出一个基础结果
max=max(dp[i])
max=max(dp[i])
max=max(dp[i])
根据归纳法的需要我要得出dp(i)的表达式
这里面可以这样分析:在之前的任何一个dp{1…i-1})中有一个最后一个值小于当前值的最大序列将这个序列+1就是dp(i)
数学表达为
dp(i)={1nums[i]<=nums[j]max{dp(j)+1∣j∈[0,i−1]}nums[i]>nums[j]
dp(i)=\begin{cases}
1&nums[i]<=nums[j]
\\max\{dp(j)+1|j\in[0,i-1]\}&nums[i]>nums[j]
\end{cases}
dp(i)={1max{dp(j)+1∣j∈[0,i−1]}nums[i]<=nums[j]nums[i]>nums[j]
最终实现
public int lengthOfLIS(int[] nums){
int n=nums.length;
int[] dp=new int[n];
Arrays.fill(dp,1);
for(int i=0;i<n;i++){
for(int j=0;j<i;j++){
if(nums[i]>nums[j]){
dp[i]=Math.max(dp[i],dp[j]+1);
}
}
}
int res=0;
for(int d:dp){
res=Math.max(res,d);
}
return res;
}
<例子四>编辑距离
给定两个字符串S1和S2只能用三种操作,删除,修改,增加,把S1变为S2,求这个最小的编辑距离。
思路其实就是定义三种操作具体的对应关系,可以先定义状态函数dp(i,j)
然后当对应字符不同时依次尝试三种操作取最小的那一个操作数学表达式可以为
dp(i,j)={dp(i−1,j−1)S1[i]=S2[j]min(dp(i,j−1)+1,dp(i−1,j)+1,dp(i−1,j−1)+1)S1[i]!=S2[j]
dp(i,j)=\begin{cases}
dp(i-1,j-1)&S1[i]=S2[j]\\
min(dp(i,j-1)+1,dp(i-1,j)+1,dp(i-1,j-1)+1)&S1[i]!=S2[j]
\end{cases}
dp(i,j)={dp(i−1,j−1)min(dp(i,j−1)+1,dp(i−1,j)+1,dp(i−1,j−1)+1)S1[i]=S2[j]S1[i]!=S2[j]
其中关键是理解三种操作对应的公式,以及base case对应的含义
- dp(i,j-1)+1 表示插入:S1插入一个字符后S2前移一格继续对比
- dp(i-1,j)+1 表示删除:S2删除一个字符后S1自动前移
- dp(i-1,j-1)+1 表示替换:替换后两个都前移
S1\S2 | “” | a | p | p | l | e |
---|---|---|---|---|---|---|
“” | 0 | 1 | 2 | 3 | 4 | 5 |
r | 1 | 1 | 2 | 3 | 4 | 5 |
a | 2 | 1 | 2 | 3 | 4 | 5 |
d | 3 | 2 | 2 | 3 | 4 | 5 |
表格中除了反应出状态的改变,同时可以得出base case就是当对应的另一个字符串为空时,直接加上剩余的字符长度。另外就是初始数组第一行及第一列。
完整代码:
public int minDistace(String s1,String s2){
int m=s1.length(),n=s2.length();
int[][] dp=new int[m+1][n+1];
for(int i=1;i<=m;i++){
dp[i][0]=i;
}
for(int j=1;j<=n;j++){
dp[0][j]=j;
}
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(s1.charAt(i)==s2.charAt(j)){
dp[i][j]==dp[i-1][j-1];
}else{
dp[i][j]=Math.min(Math.min(dp[i-1][j]+1,dp[i-1][j-1]+1),dp[i][j-1]+1);
}
}
}
return dp[m][n];
}